chore: build
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s

This commit is contained in:
Xor290
2026-08-06 12:06:05 +02:00
parent c034088bee
commit 22a8d5026c
174 changed files with 30315 additions and 16120 deletions
+22 -6
View File
@@ -3,19 +3,35 @@ package db
import ( import (
"fmt" "fmt"
"gestion/models" "gestion/models"
"gestion/utils"
"strings"
) )
func (d *Database) CheckAddress(addressByUser *models.Command) error { func (d *Database) CheckAddress(addressByUser *models.Command) error {
var correction models.Address var correction models.Address
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction) result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
if result.Error != nil { if result.Error == nil {
if isNotFound(result.Error) { addressByUser.DeliveryAddress = correction.CorrectAddress
return nil return fmt.Errorf("adresse invalide %s", correction.CorrectAddress)
} }
if !isNotFound(result.Error) {
return fmt.Errorf("checkAddress: %w", result.Error) return fmt.Errorf("checkAddress: %w", result.Error)
} }
addressByUser.DeliveryAddress = correction.CorrectAddress
return fmt.Errorf("Adresse invalide %s", correction.CorrectAddress) // Pas de correspondance exacte — fallback sur une comparaison normalisée
// (accents/casse/espaces) pour rattraper les variantes mineures de saisie.
corrections, err := d.AllAddress()
if err != nil {
return nil
}
normalizedInput := utils.NormalizeAddress(addressByUser.DeliveryAddress)
for _, c := range corrections {
if strings.EqualFold(utils.NormalizeAddress(c.InvalidAddress), normalizedInput) {
addressByUser.DeliveryAddress = c.CorrectAddress
return fmt.Errorf("adresse invalide %s", c.CorrectAddress)
}
}
return nil
} }
func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error { func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error {
+3 -3
View File
@@ -27,7 +27,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) { func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy var alerts []models.AlertPolicy
if err := d.GDB.Find(&alerts).Error; err != nil { if err := d.GDB.Order("created_at DESC").Limit(500).Find(&alerts).Error; err != nil {
return nil, err return nil, err
} }
return alerts, nil return alerts, nil
@@ -65,7 +65,7 @@ func (d *Database) ActivateAlert(id int) error {
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) { func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy var alerts []models.AlertPolicy
if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Find(&alerts).Error; err != nil { if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Limit(100).Find(&alerts).Error; err != nil {
return nil, err return nil, err
} }
return alerts, nil return alerts, nil
@@ -73,7 +73,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) { func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy var alerts []models.AlertPolicy
if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Find(&alerts).Error; err != nil { if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Limit(200).Find(&alerts).Error; err != nil {
return nil, err return nil, err
} }
return alerts, nil return alerts, nil
+33 -51
View File
@@ -7,7 +7,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée (legacy)
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) { func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var result struct { var result struct {
Price float64 `gorm:"column:price"` Price float64 `gorm:"column:price"`
@@ -49,27 +48,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) { func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
var baskets []models.Panier var baskets []models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error { err := d.GDB.Transaction(func(tx *gorm.DB) error {
// Supprimer tout article récompense existant (remplacement) var err error
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username) baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
for _, item := range items { return err
if item.ProductID <= 0 || item.Quantity <= 0 {
continue
}
var productName string
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
return fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
}
var basket models.Panier
if err := tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
return err
}
baskets = append(baskets, basket)
}
return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -77,6 +58,36 @@ func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem
return baskets, nil return baskets, nil
} }
// addRewardsToBasketTx contient la logique de remplacement des articles
// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket),
// soit dans la même transaction qu'une autre opération (voir
// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est
// jamais consommée sans que son produit soit effectivement livré.
func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
// Supprimer tout article récompense existant (remplacement)
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
var baskets []models.Panier
for _, item := range items {
if item.ProductID <= 0 || item.Quantity <= 0 {
continue
}
var productName string
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" {
return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID)
}
var basket models.Panier
if err := tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
return nil, err
}
baskets = append(baskets, basket)
}
return baskets, nil
}
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense. // HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
func (d *Database) HasOnlyRewardItems(username string) (bool, error) { func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
var counts struct { var counts struct {
@@ -164,35 +175,6 @@ func (d *Database) ClearBasket(username string) error {
return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
} }
// ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier.
// C'est ici que le stock est effectivement consommé, au moment de la validation de la commande.
func (d *Database) ClearBasketOnCheckout(username string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var items []struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
}
if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil {
return fmt.Errorf("erreur lecture panier: %w", err)
}
for _, item := range items {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
}
if currentStock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
}
if err := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
}
}
return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error
})
}
func (d *Database) GetBasketItemOwner(basketID int) (string, error) { func (d *Database) GetBasketItemOwner(basketID int) (string, error) {
var username string var username string
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
+104 -19
View File
@@ -78,9 +78,14 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
if err := tx.Exec(` if err := tx.Exec(`
UPDATE products p UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM command_items ci FROM (
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil { SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
return fmt.Errorf("erreur remboursement stock: %w", err) return fmt.Errorf("erreur remboursement stock: %w", err)
} }
log.Printf("✅ [CancelAtomic] Stock remboursé") log.Printf("✅ [CancelAtomic] Stock remboursé")
@@ -145,20 +150,18 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
return penalty, nil return penalty, nil
} }
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool { func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
etaKey := fmt.Sprintf("command:eta:%d", commandID) etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result() etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
if err != nil { if err != nil || len(etaData) == 0 {
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID) log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
return false return false
} }
var etaMinutes int var etaMinutes int
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes) if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
if err != nil || etaMinutes <= 0 { log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
return false return false
} }
@@ -192,13 +195,18 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username) log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username)
// ✅ Ne restitue le stock QUE si pas déjà fait // ✅ Ne restitue le stock QUE si pas déjà fait
stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" || cmdResult.Status == "livre"
if !stockAlreadyRestored { if !stockAlreadyRestored {
if err := tx.Exec(` if err := tx.Exec(`
UPDATE products p UPDATE products p
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM command_items ci FROM (
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil { SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err) log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
} else { } else {
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status) log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
@@ -316,12 +324,89 @@ func (d *Database) AddClientPenalty(username string, points int) error {
return nil return nil
} }
func (d *Database) RestoreCommandStock(commandID int) error { // CancelCommandByAdminAtomic transitionne une commande vers 'cancelled' depuis le
// panel admin/cabine de façon atomique (verrou FOR UPDATE sur la commande) : le
// remboursement de stock et le changement de statut se font dans la même
// transaction, conditionnés à une lecture du statut précédent faite sous verrou.
// Corrige un double remboursement possible sur double-tap/appel concurrent —
// l'ancien code (RestoreCommandStock + UpdateCommandStatus appelés séparément
// par le handler) lisait le statut puis restaurait le stock hors transaction,
// laissant une fenêtre où deux requêtes concurrentes lisaient toutes les deux
// "pas encore annulée" et remboursaient chacune le stock.
func (d *Database) CancelCommandByAdminAtomic(commandID int) error {
return d.GDB.Transaction(func(tx *gorm.DB) error { return d.GDB.Transaction(func(tx *gorm.DB) error {
return tx.Exec(` var prevStatus string
UPDATE products p if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil {
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP return err
FROM command_items ci }
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error if prevStatus == "" {
return fmt.Errorf("commande non trouvée")
}
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
if !slices.Contains(noRestoreStatuses, prevStatus) {
if err := tx.Exec(`
UPDATE products p
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM (
SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; err != nil {
return fmt.Errorf("erreur remboursement stock: %w", err)
}
}
if err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, commandID).Error; err != nil {
return fmt.Errorf("erreur mise à jour statut: %w", err)
}
return nil
}) })
} }
// CancelDeliveryByLivreurAtomic annule une commande côté livreur et restaure le stock
// de manière atomique (verrou FOR UPDATE + transition conditionnée à l'ancien statut).
// Idempotent : si la commande est déjà annulée, ne touche pas au stock et renvoie
// alreadyCancelled=true — évite un remboursement en double en cas de double appel
// (double-tap, retry réseau, ou commande déjà annulée par un autre canal).
func (d *Database) CancelDeliveryByLivreurAtomic(commandID int) (alreadyCancelled bool, prevStatus string, err error) {
err = d.GDB.Transaction(func(tx *gorm.DB) error {
if e := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; e != nil {
return e
}
if prevStatus == "" {
return fmt.Errorf("commande non trouvée")
}
if prevStatus == "cancelled" {
alreadyCancelled = true
return nil
}
result := tx.Exec(`
UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ?`, commandID, prevStatus)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande déjà modifiée par une autre requête")
}
if e := tx.Exec(`
UPDATE products p
SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP
FROM (
SELECT product_id, SUM(quantite) AS total_qty
FROM command_items
WHERE command_id = ?
GROUP BY product_id
) agg
WHERE agg.product_id = p.id`, commandID).Error; e != nil {
return fmt.Errorf("erreur remboursement stock: %w", e)
}
return nil
})
return
}
+17 -2
View File
@@ -13,6 +13,7 @@ type Category struct {
Name string `json:"name" gorm:"column:name"` Name string `json:"name" gorm:"column:name"`
Color string `json:"color" gorm:"column:color"` Color string `json:"color" gorm:"column:color"`
IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"` IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"`
Position int `json:"position" gorm:"column:position"`
CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"`
} }
@@ -30,7 +31,7 @@ func ValidateCategoryColor(color string) error {
func (d *Database) GetAllCategories() ([]Category, error) { func (d *Database) GetAllCategories() ([]Category, error) {
var categories []Category var categories []Category
if err := d.GDB.Order("name ASC").Find(&categories).Error; err != nil { if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil {
return nil, err return nil, err
} }
if categories == nil { if categories == nil {
@@ -43,7 +44,9 @@ func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Categ
if color == "" { if color == "" {
color = "#7c3aed" color = "#7c3aed"
} }
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon} var maxPos int
d.GDB.Model(&Category{}).Select("COALESCE(MAX(position), 0)").Scan(&maxPos)
c := Category{Name: name, Color: color, IsComingSoon: isComingSoon, Position: maxPos + 1}
if err := d.GDB.Create(&c).Error; err != nil { if err := d.GDB.Create(&c).Error; err != nil {
return nil, err return nil, err
} }
@@ -82,6 +85,18 @@ func (d *Database) DeleteCategory(id int) error {
return nil return nil
} }
// ReorderCategories met à jour les positions selon l'ordre du tableau d'IDs fourni.
func (d *Database) ReorderCategories(ids []int) error {
tx := d.GDB.Begin()
for i, id := range ids {
if err := tx.Model(&Category{}).Where("id = ?", id).Update("position", i+1).Error; err != nil {
tx.Rollback()
return err
}
}
return tx.Commit().Error
}
func (d *Database) CategoryExists(name string) (bool, error) { func (d *Database) CategoryExists(name string) (bool, error) {
var count int64 var count int64
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
+88 -35
View File
@@ -32,6 +32,7 @@ func (d *Database) CreateClient(client *models.Client) error {
return nil return nil
} }
// GetClientByID récupère un client par son ID
func (d *Database) GetClientByID(id int) (*models.Client, error) { func (d *Database) GetClientByID(id int) (*models.Client, error) {
var row struct { var row struct {
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
@@ -75,6 +76,7 @@ func (d *Database) GetClientByID(id int) (*models.Client, error) {
return client, nil return client, nil
} }
// GetAllClients récupère tous les clients
func (d *Database) GetAllClients() ([]*models.Client, error) { func (d *Database) GetAllClients() ([]*models.Client, error) {
var rows []struct { var rows []struct {
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
@@ -290,6 +292,28 @@ func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error
} }
// GetClientByUsername récupère un client par son username // GetClientByUsername récupère un client par son username
// GetClientsByUsernames charge plusieurs clients en une seule requête.
// Retourne map[username]*Client ; les usernames sans correspondance sont absents de la map.
func (d *Database) GetClientsByUsernames(usernames []string) (map[string]*models.Client, error) {
result := make(map[string]*models.Client, len(usernames))
if len(usernames) == 0 {
return result, nil
}
var rows []struct {
ID int `gorm:"column:id"`
Username string `gorm:"column:username"`
Nom string `gorm:"column:nom"`
Prenom string `gorm:"column:prenom"`
}
if err := d.GDB.Raw(`SELECT id, username, nom, prenom FROM clients WHERE username IN ?`, usernames).Scan(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.Username] = &models.Client{ID: r.ID, Username: r.Username, Nom: r.Nom, Prenom: r.Prenom}
}
return result, nil
}
func (d *Database) GetClientByUsername(username string) (*models.Client, error) { func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
var row struct { var row struct {
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
@@ -713,52 +737,81 @@ func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[s
return pointsExtra, pointsRedeemed, nil return pointsExtra, pointsRedeemed, nil
} }
// claimPoolRewardTx vérifie l'éligibilité et consomme une récompense pour un
// pool donné, dans la transaction fournie — factorisée pour être appelée
// seule (ClaimPoolReward) ou combinée avec la livraison du produit dans la
// même transaction (ClaimPoolRewardAndAddToBasket), afin qu'une récompense
// ne soit jamais consommée sans que son produit soit effectivement livré.
func claimPoolRewardTx(tx *gorm.DB, username, poolKey string, threshold int) (remainingAvailable int, err error) {
var row struct {
Points int `gorm:"column:pts"`
Redeemed int `gorm:"column:redeemed"`
}
if err := tx.Raw(`
SELECT
COALESCE((points_extra->>?)::int, 0) as pts,
COALESCE((points_redeemed->>?)::int, 0) as redeemed
FROM clients WHERE username = ? FOR UPDATE`,
poolKey, poolKey, username).Scan(&row).Error; err != nil {
return 0, fmt.Errorf("erreur lecture: %w", err)
}
earned := row.Points / threshold
available := earned - row.Redeemed
if available <= 0 {
return 0, fmt.Errorf("pas de récompense disponible pour ce pool")
}
if err := tx.Exec(`
UPDATE clients
SET points_redeemed = jsonb_set(
COALESCE(points_redeemed, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, username).Error; err != nil {
return 0, err
}
return earned - (row.Redeemed + 1), nil
}
// ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points. // ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points.
// Retourne le nombre de récompenses disponibles restantes après la réclamation. // Retourne le nombre de récompenses disponibles restantes après la réclamation.
func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) { func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) {
var points, redeemed int
err = d.GDB.Transaction(func(tx *gorm.DB) error { err = d.GDB.Transaction(func(tx *gorm.DB) error {
var row struct { var err error
Points int `gorm:"column:pts"` remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
Redeemed int `gorm:"column:redeemed"` return err
}
if err := tx.Raw(`
SELECT
COALESCE((points_extra->>?)::int, 0) as pts,
COALESCE((points_redeemed->>?)::int, 0) as redeemed
FROM clients WHERE username = ? FOR UPDATE`,
poolKey, poolKey, username).Scan(&row).Error; err != nil {
return fmt.Errorf("erreur lecture: %w", err)
}
points = row.Points
redeemed = row.Redeemed
earned := points / threshold
available := earned - redeemed
if available <= 0 {
return fmt.Errorf("pas de récompense disponible pour ce pool")
}
return tx.Exec(`
UPDATE clients
SET points_redeemed = jsonb_set(
COALESCE(points_redeemed, '{}'::jsonb),
ARRAY[?],
to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1)
), updated_at = CURRENT_TIMESTAMP
WHERE username = ?`,
poolKey, poolKey, username).Error
}) })
if err != nil { if err != nil {
return 0, err return 0, err
} }
earned := points / threshold
remainingAvailable = earned - (redeemed + 1)
return remainingAvailable, nil return remainingAvailable, nil
} }
func (d *Database) ClaimPoolRewardAndAddToBasket(username, poolKey string, threshold int, items []models.RewardItem) (remainingAvailable int, added []models.Panier, err error) {
err = d.GDB.Transaction(func(tx *gorm.DB) error {
var err error
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
if err != nil {
return err
}
if len(items) > 0 {
added, err = addRewardsToBasketTx(tx, username, items, poolKey)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, nil, err
}
return remainingAvailable, added, nil
}
// ResetClientRedeemed remet à zéro les récompenses réclamées (admin). // ResetClientRedeemed remet à zéro les récompenses réclamées (admin).
func (d *Database) ResetClientRedeemed(username, poolKey string) error { func (d *Database) ResetClientRedeemed(username, poolKey string) error {
if poolKey != "" { if poolKey != "" {
+167 -52
View File
@@ -6,8 +6,37 @@ import (
"slices" "slices"
"strings" "strings"
"time" "time"
"gorm.io/gorm"
) )
// commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client.
type commandItemFull struct {
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID int `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"`
ClientTelephone string `gorm:"column:client_telephone"`
DeliveryAddress string `gorm:"column:delivery_address"`
Status string `gorm:"column:status"`
}
func (commandItemFull) TableName() string { return "command_items" }
// InsertCommandItemsBatch insère plusieurs items en une seule requête.
func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error {
if len(items) == 0 {
return nil
}
return d.GDB.Create(&items).Error
}
// ============================================ // ============================================
// VALIDATION HELPERS // VALIDATION HELPERS
// ============================================ // ============================================
@@ -308,6 +337,92 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
return items, nil return items, nil
} }
// GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête.
// Retourne map[commandID][]items, même structure que GetCommandItems.
func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) {
result := make(map[int][]map[string]any, len(commandIDs))
if len(commandIDs) == 0 {
return result, nil
}
var rows []struct {
ID int `gorm:"column:id"`
CommandID int `gorm:"column:command_id"`
Produit string `gorm:"column:produit"`
ProductID *int64 `gorm:"column:product_id"`
Quantite float64 `gorm:"column:quantite"`
Prix float64 `gorm:"column:prix"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
ClientUsername string `gorm:"column:client_username"`
ClientNom string `gorm:"column:client_nom"`
ClientPrenom string `gorm:"column:client_prenom"`
ClientTelephone string `gorm:"column:client_telephone"`
DeliveryAddress *string `gorm:"column:delivery_address"`
Status *string `gorm:"column:status"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
CommandStatus *string `gorm:"column:command_status"`
CommandAddress *string `gorm:"column:command_address"`
TotalPrix float64 `gorm:"column:total_prix"`
ReferralUsed float64 `gorm:"column:referral_used"`
LivreurAssign *string `gorm:"column:livreur_assign"`
CommandCreatedAt *time.Time `gorm:"column:command_created_at"`
Category string `gorm:"column:category"`
Unit string `gorm:"column:unit"`
ClientOrderNumber int `gorm:"column:client_order_number"`
}
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.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,
c.total_prix, c.referral_used, c.livreur_assign,
c.created_at as command_created_at,
COALESCE(p.category, '') as category,
COALESCE(p.unit, '') as unit,
c.client_order_id as client_order_number
FROM command_items ci
LEFT JOIN commandes c ON ci.command_id = c.id
LEFT JOIN products p ON ci.product_id = p.id
WHERE ci.command_id IN ?
ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("erreur récupération items batch: %w", err)
}
for _, row := range rows {
productIDValue := 0
if row.ProductID != nil {
productIDValue = int(*row.ProductID)
}
var commandCreatedAt any
if row.CommandCreatedAt != nil {
commandCreatedAt = *row.CommandCreatedAt
}
item := map[string]any{
"id": row.ID, "command_id": row.CommandID,
"produit": row.Produit, "product_id": productIDValue,
"quantite": row.Quantite, "prix": row.Prix,
"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,
"delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status),
"created_at": row.CreatedAt, "updated_at": row.UpdatedAt,
"command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress),
"total_prix": row.TotalPrix, "referral_used": row.ReferralUsed,
"livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt,
"category": row.Category, "unit": row.Unit,
"client_order_number": row.ClientOrderNumber,
}
result[row.CommandID] = append(result[row.CommandID], item)
}
return result, nil
}
// ptrStr retourne la valeur d'un *string ou "" si nil // ptrStr retourne la valeur d'un *string ou "" si nil
func ptrStr(s *string) string { func ptrStr(s *string) string {
if s == nil { if s == nil {
@@ -316,6 +431,12 @@ func ptrStr(s *string) string {
return *s return *s
} }
// DeleteCommandItem supprime un item d'une commande et restaure son stock si
// la commande n'est pas déjà dans un état terminal. Le statut de la commande
// est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction
// que la suppression et le remboursement, pour éviter une course avec une
// annulation concurrente de la commande entière (qui rembourserait déjà cet
// item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin.
func (d *Database) DeleteCommandItem(commandID, itemID int) error { func (d *Database) DeleteCommandItem(commandID, itemID int) error {
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID) log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
@@ -326,61 +447,55 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
return err return err
} }
var result struct { return d.GDB.Transaction(func(tx *gorm.DB) error {
Prix float64 `gorm:"column:prix"` var cmdStatus string
Quantite float64 `gorm:"column:quantite"` if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
ProductID int `gorm:"column:product_id"` return fmt.Errorf("erreur vérification commande: %w", err)
} }
if err := d.GDB.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil { if cmdStatus == "" {
return fmt.Errorf("erreur vérification item: %w", err) return fmt.Errorf("commande %d non trouvée", commandID)
}
if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
var cmdStatus string
d.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&cmdStatus)
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
tx := d.GDB.Begin()
if tx.Error != nil {
return fmt.Errorf("erreur démarrage transaction: %w", tx.Error)
}
if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
tx.Rollback()
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
if err := tx.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID,
).Error; err != nil {
tx.Rollback()
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
return fmt.Errorf("erreur mise à jour total commande: %w", err)
}
if restoreStock {
if err := tx.Exec(
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
result.Quantite, result.ProductID,
).Error; err != nil {
tx.Rollback()
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
return fmt.Errorf("erreur restauration stock: %w", err)
} }
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
}
if err := tx.Commit().Error; err != nil { var result struct {
return fmt.Errorf("erreur commit transaction: %w", err) Prix float64 `gorm:"column:prix"`
} Quantite float64 `gorm:"column:quantite"`
ProductID int `gorm:"column:product_id"`
}
if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil {
return fmt.Errorf("erreur vérification item: %w", err)
}
if result.Prix == 0 && result.Quantite == 0 {
return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID)
}
return nil if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil {
log.Printf("❌ Erreur DELETE command_items: %v", err)
return fmt.Errorf("erreur suppression item: %w", err)
}
if err := tx.Exec(
`UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`,
result.Prix*result.Quantite, commandID,
).Error; err != nil {
log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err)
return fmt.Errorf("erreur mise à jour total commande: %w", err)
}
noRestoreStatuses := []string{"cancelled", "approved", "livre"}
restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus)
if restoreStock {
if err := tx.Exec(
`UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
result.Quantite, result.ProductID,
).Error; err != nil {
log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err)
return fmt.Errorf("erreur restauration stock: %w", err)
}
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
}
return nil
})
} }
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error { func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
+144 -158
View File
@@ -1,6 +1,8 @@
package db package db
import ( import (
"encoding/json"
"errors"
"fmt" "fmt"
"gestion/models" "gestion/models"
"log" "log"
@@ -11,6 +13,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
) )
// errAlreadyApproved est retournée quand le client tente d'approuver une commande déjà approuvée.
var errAlreadyApproved = errors.New("already_approved")
func sanitizeString(s string) string { func sanitizeString(s string) string {
sanitized := strings.Map(func(r rune) rune { sanitized := strings.Map(func(r rune) rune {
if r < 32 || r == 127 { if r < 32 || r == 127 {
@@ -52,18 +57,6 @@ type basketItem struct {
RewardPoolKey string `gorm:"column:reward_pool_key"` RewardPoolKey string `gorm:"column:reward_pool_key"`
} }
func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) {
var items []basketItem
if err := d.GDB.Table("baskets").Select("product_id, quantity, price, is_reward, reward_pool_key").Where("username = ?", username).Scan(&items).Error; err != nil {
return nil, 0, fmt.Errorf("erreur récupération panier: %w", err)
}
total := 0.0
for _, item := range items {
total += item.Price
}
return items, total, nil
}
// validateCommandStatus vérifie si le statut est valide // validateCommandStatus vérifie si le statut est valide
func validateCommandStatus(status string) error { func validateCommandStatus(status string) error {
validStatuses := map[string]bool{ validStatuses := map[string]bool{
@@ -84,74 +77,6 @@ func validateCommandStatus(status string) error {
return nil return nil
} }
func (d *Database) CreateCommand(username string) (*models.Command, error) {
adresse := "Adresse non spécifiée"
var clientCheck models.Client
if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" {
adresse = clientCheck.Username
}
basketItems, totalPrix, err := d.fetchBasketItems(username)
if err != nil {
return nil, err
}
if len(basketItems) == 0 {
return nil, fmt.Errorf("le panier est vide")
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
err = d.GDB.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error
if err != nil {
return nil, fmt.Errorf("erreur lors de la création de la commande: %w", err)
}
commandID := cmdResult.ID
for _, item := range basketItems {
productName, err := d.GetProductNameByID(item.ProductID)
if err != nil {
productName = "Produit inconnu"
}
cmdItem := models.CommandItem{
CommandID: commandID,
Produit: productName,
ProductID: item.ProductID,
Quantity: item.Quantity,
Price: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
}
if err := d.GDB.Create(&cmdItem).Error; err != nil {
return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err)
}
}
if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
return nil, fmt.Errorf("erreur lors du vidage du panier: %w", err)
}
command := &models.Command{
ID: commandID,
ClientOrderID: cmdResult.ClientOrderID,
Status: "pending",
Total: totalPrix,
}
return command, nil
}
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) { func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
if err := validateUsername(username); err != nil { if err := validateUsername(username); err != nil {
return nil, err return nil, err
@@ -175,92 +100,122 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
clientTelephone = sanitizeString(client.Telephone) clientTelephone = sanitizeString(client.Telephone)
} }
basketItems, totalPrix, err := d.fetchBasketItems(username) var (
command *models.Command
totalPrix float64
)
err = d.GDB.Transaction(func(tx *gorm.DB) error {
// Verrou sur le panier : un double-submit concurrent du même client se
// 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 {
return fmt.Errorf("erreur récupération panier: %w", err)
}
if len(basketItems) == 0 {
return fmt.Errorf("le panier est vide")
}
for _, item := range basketItems {
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
return fmt.Errorf("données panier invalides")
}
totalPrix += item.Price
}
if totalPrix <= 0 || totalPrix > 100000 {
return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
if err := tx.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil {
return fmt.Errorf("erreur création commande: %w", err)
}
commandID := cmdResult.ID
productIDs2 := make([]int, 0, len(basketItems))
for _, item := range basketItems {
productIDs2 = append(productIDs2, item.ProductID)
}
productNames2, _ := d.GetProductNamesByIDs(productIDs2)
batchItems := make([]commandItemFull, 0, len(basketItems))
for _, item := range basketItems {
productName := productNames2[item.ProductID]
if productName == "" {
productName = fmt.Sprintf("Produit #%d", item.ProductID)
}
batchItems = append(batchItems, commandItemFull{
CommandID: commandID,
Produit: productName,
ProductID: item.ProductID,
Quantite: item.Quantity,
Prix: item.Price,
IsReward: item.IsReward,
RewardPoolKey: item.RewardPoolKey,
ClientUsername: username,
ClientNom: clientNom,
ClientPrenom: clientPrenom,
ClientTelephone: clientTelephone,
DeliveryAddress: deliveryAddress,
Status: "pending",
})
}
if err := tx.Create(&batchItems).Error; err != nil {
return fmt.Errorf("erreur insertion items: %w", err)
}
// Les articles récompense (payés en points) restent des produits physiques
// réellement distribués : le stock doit être décrémenté comme pour un
// article payant.
for _, item := range basketItems {
var currentStock float64
if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(&currentStock).Error; err != nil {
return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err)
}
if currentStock < item.Quantity {
return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID)
}
if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil {
return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err)
}
}
if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
return err
}
command = &models.Command{
ID: commandID,
ClientOrderID: cmdResult.ClientOrderID,
Username: username,
Status: "pending",
Total: totalPrix,
DeliveryAddress: deliveryAddress,
CreatedAt: cmdResult.CreatedAt,
UpdatedAt: cmdResult.UpdatedAt,
}
return nil
})
if err != nil { if err != nil {
log.Printf("❌ Erreur query basket: %v", err) log.Printf("❌ Erreur création commande: %v", err)
return nil, err return nil, err
} }
if len(basketItems) == 0 {
return nil, fmt.Errorf("le panier est vide")
}
for _, item := range basketItems {
if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 {
return nil, fmt.Errorf("données panier invalides")
}
}
if totalPrix <= 0 || totalPrix > 100000 {
return nil, fmt.Errorf("montant de commande invalide: %.2f€", totalPrix)
}
var cmdResult struct {
ID int `gorm:"column:id"`
ClientOrderID int `gorm:"column:client_order_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
err = d.GDB.Raw(`
INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at)
VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, client_order_id, created_at, updated_at`,
username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error
if err != nil {
return nil, fmt.Errorf("erreur création commande: %w", err)
}
commandID := cmdResult.ID
for _, item := range basketItems {
productName, err := d.GetProductNameByID(item.ProductID)
if err != nil || productName == "" {
productName = fmt.Sprintf("Produit #%d", item.ProductID)
}
err = d.InsertCommandItemWithClientInfo(
commandID,
productName,
item.ProductID,
item.Quantity,
item.Price,
item.IsReward,
item.RewardPoolKey,
username,
clientNom,
clientPrenom,
clientTelephone,
deliveryAddress,
)
if err != nil {
log.Printf("❌ Erreur INSERT command_items: %v", err)
return nil, fmt.Errorf("erreur insertion items: %w", err)
}
// Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici.
}
if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil {
log.Printf("⚠️ Erreur vidage panier: %v", err)
}
sanitizedAddress := sanitizeLogMessage(deliveryAddress) sanitizedAddress := sanitizeLogMessage(deliveryAddress)
d.AddCommandLog(commandID, "created", d.AddCommandLog(command.ID, "created",
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s", fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)), sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
username) username)
command := &models.Command{
ID: commandID,
ClientOrderID: cmdResult.ClientOrderID,
Username: username,
Status: "pending",
Total: totalPrix,
DeliveryAddress: deliveryAddress,
CreatedAt: cmdResult.CreatedAt,
UpdatedAt: cmdResult.UpdatedAt,
}
return command, nil return command, nil
} }
@@ -424,9 +379,28 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
return command, nil return command, nil
} }
const lastDeliveryCoordsCacheTTL = 5 * time.Minute
func lastDeliveryCoordsCacheKey(livreurUsername string) string {
return fmt.Sprintf("livreur:last_delivery_coords:%s", livreurUsername)
}
// GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur. // GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur.
// Utilisé comme fallback quand le GPS temps réel est indisponible. // Utilisé comme fallback quand le GPS temps réel est indisponible. Mis en cache quelques minutes
// car appelé à chaque calcul d'ETA et la dernière livraison ne change pas souvent.
func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) { func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) {
cacheKey := lastDeliveryCoordsCacheKey(livreurUsername)
if cached, err := Redis.Get(RedisCtx, cacheKey).Result(); err == nil {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if jsonErr := json.Unmarshal([]byte(cached), &coords); jsonErr == nil {
return coords.Lat, coords.Lon, nil
}
}
var result struct { var result struct {
DestLatitude float64 `gorm:"column:dest_latitude"` DestLatitude float64 `gorm:"column:dest_latitude"`
DestLongitude float64 `gorm:"column:dest_longitude"` DestLongitude float64 `gorm:"column:dest_longitude"`
@@ -446,6 +420,10 @@ func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float
return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername) return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername)
} }
if coordsJSON, err := json.Marshal(map[string]float64{"lat": result.DestLatitude, "lon": result.DestLongitude}); err == nil {
Redis.Set(RedisCtx, cacheKey, coordsJSON, lastDeliveryCoordsCacheTTL)
}
return result.DestLatitude, result.DestLongitude, nil return result.DestLatitude, result.DestLongitude, nil
} }
@@ -772,6 +750,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
return fmt.Errorf("cette commande ne vous appartient pas") return fmt.Errorf("cette commande ne vous appartient pas")
} }
if cmd.Status == "approved" {
log.Printf("ℹ️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID)
return errAlreadyApproved
}
if cmd.Status != "livre" { if cmd.Status != "livre" {
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status) log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status)
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status) return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
@@ -821,6 +804,9 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
}) })
if err != nil { if err != nil {
if errors.Is(err, errAlreadyApproved) {
return 0, "", nil
}
return 0, "", err return 0, "", err
} }
-1
View File
@@ -95,7 +95,6 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
}) })
} }
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) { func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) {
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
FROM commandes FROM commandes
-4
View File
@@ -25,20 +25,16 @@ func wazeAppLink(lat, lon float64) string {
return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon) return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon)
} }
// GenerateMapLinks génère tous les liens de cartes pour une position GPS
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks { func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
return MapLinks{ return MapLinks{
WazeApp: wazeAppLink(lat, lon), WazeApp: wazeAppLink(lat, lon),
} }
} }
// GenerateNavigationLink génère un lien de navigation vers une destination
// fromLat/fromLon sont ignorés : Waze part toujours de la position GPS courante
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string { func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
return wazeAppLink(toLat, toLon) return wazeAppLink(toLat, toLon)
} }
// GenerateMapLinksForCommand génère les liens de navigation pour une commande
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) { func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername) _, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
if err != nil { if err != nil {
+62 -2
View File
@@ -53,7 +53,7 @@ func InitDB() *Database {
// Configuration du pool de connexions // Configuration du pool de connexions
db.SetMaxOpenConns(50) db.SetMaxOpenConns(50)
db.SetMaxIdleConns(10) db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute) db.SetConnMaxLifetime(30 * time.Minute)
// Tester la connexion // Tester la connexion
if err = db.Ping(); err != nil { if err = db.Ping(); err != nil {
@@ -66,7 +66,9 @@ func InitDB() *Database {
gormDB, err := gorm.Open(postgres.New(postgres.Config{ gormDB, err := gorm.Open(postgres.New(postgres.Config{
Conn: db, Conn: db,
}), &gorm.Config{ }), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent), SkipDefaultTransaction: true,
PrepareStmt: true,
Logger: logger.Default.LogMode(logger.Silent),
}) })
if err != nil { if err != nil {
log.Fatalf("❌ Erreur initialisation GORM: %v", err) log.Fatalf("❌ Erreur initialisation GORM: %v", err)
@@ -165,6 +167,23 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err) log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
} }
// Migration: position d'affichage des catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration categories.position: %v", err)
}
// Backfill: attribuer des positions aux catégories existantes (ordre alphabétique)
if _, err = database.Exec(`
UPDATE categories c
SET position = sub.rn
FROM (
SELECT id, ROW_NUMBER() OVER (ORDER BY name ASC) AS rn
FROM categories
) sub
WHERE c.id = sub.id AND c.position = 0
`); err != nil {
log.Fatalf("❌ Erreur backfill categories.position: %v", err)
}
// Migration: table paramètres globaux de l'application // Migration: table paramètres globaux de l'application
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings ( if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY, key VARCHAR(100) PRIMARY KEY,
@@ -277,6 +296,21 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration contacts: %v", err) log.Fatalf("❌ Erreur migration contacts: %v", err)
} }
// Migration: clé RustFS pour les médias (stockage objet)
if _, err = database.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS key TEXT NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration media.key: %v", err)
}
// Migration: colonne parrain sur les clients (système de parrainage)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS parrain VARCHAR(255) DEFAULT NULL`); err != nil {
log.Fatalf("❌ Erreur migration clients.parrain: %v", err)
}
// Migration: index sur clients.parrain (lookups filleuls + stats parrainage)
if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_clients_parrain ON clients(parrain) WHERE parrain IS NOT NULL`); err != nil {
log.Fatalf("❌ Erreur migration idx_clients_parrain: %v", err)
}
// Lancer le nettoyage périodique des tokens expirés // Lancer le nettoyage périodique des tokens expirés
go database.cleanExpiredTokensPeriodically() go database.cleanExpiredTokensPeriodically()
@@ -321,6 +355,7 @@ func (db *Database) createTables() error {
cancellations_count INTEGER DEFAULT 0 NOT NULL, cancellations_count INTEGER DEFAULT 0 NOT NULL,
last_penalty_reason TEXT DEFAULT NULL, last_penalty_reason TEXT DEFAULT NULL,
referral_balance NUMERIC(10,2) DEFAULT 0.0, referral_balance NUMERIC(10,2) DEFAULT 0.0,
parrain VARCHAR(255) DEFAULT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`, );`,
@@ -382,6 +417,7 @@ func (db *Database) createTables() error {
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
url TEXT NOT NULL, url TEXT NOT NULL,
type VARCHAR(50) NOT NULL, type VARCHAR(50) NOT NULL,
key TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`, );`,
@@ -513,6 +549,30 @@ func (db *Database) createTables() error {
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL name VARCHAR(255) NOT NULL
);`, );`,
// ============================
// TABLE livreur_ratings
// ============================
`CREATE TABLE IF NOT EXISTS livreur_ratings (
id SERIAL PRIMARY KEY,
order_id INTEGER NOT NULL UNIQUE REFERENCES commandes(id) ON DELETE CASCADE,
livreur_username VARCHAR(255) NOT NULL,
client_username VARCHAR(255) NOT NULL,
rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`,
// ============================
// TABLE login_history (livreur uniquement)
// ============================
`CREATE TABLE IF NOT EXISTS login_history (
id SERIAL PRIMARY KEY,
username VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
`CREATE INDEX IF NOT EXISTS idx_login_history_username ON login_history(username);`,
} }
for _, query := range queries { for _, query := range queries {
+62
View File
@@ -0,0 +1,62 @@
package db
import (
"time"
)
type LivreurRating struct {
ID int `json:"id"`
OrderID int `json:"order_id"`
LivreurUsername string `json:"livreur_username"`
ClientUsername string `json:"client_username"`
Rating int `json:"rating"`
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
}
func (d *Database) SubmitLivreurRating(orderID int, livreurUsername, clientUsername string, rating int, comment string) error {
return d.GDB.Exec(`
INSERT INTO livreur_ratings (order_id, livreur_username, client_username, rating, comment, created_at)
VALUES (?, ?, ?, ?, ?, NOW())
`, orderID, livreurUsername, clientUsername, rating, comment).Error
}
func (d *Database) GetOrderRating(orderID int) (*LivreurRating, error) {
var r LivreurRating
err := d.GDB.Raw(`SELECT * FROM livreur_ratings WHERE order_id = ? LIMIT 1`, orderID).Scan(&r).Error
if err != nil {
return nil, err
}
if r.ID == 0 {
return nil, nil
}
return &r, nil
}
func (d *Database) GetLivreurRatings(livreurUsername string) ([]LivreurRating, float64, error) {
var ratings []LivreurRating
if err := d.GDB.Raw(`
SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC LIMIT 200
`, livreurUsername).Scan(&ratings).Error; err != nil {
return nil, 0, err
}
var avg float64
if len(ratings) > 0 {
d.GDB.Raw(`SELECT COALESCE(AVG(rating), 0) FROM livreur_ratings WHERE livreur_username = ?`, livreurUsername).Scan(&avg)
}
return ratings, avg, nil
}
// GetOrderForRating retourne l'username client et le livreur d'une commande approuvée
func (d *Database) GetOrderForRating(orderID int) (clientUsername, livreurUsername string, err error) {
var row struct {
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
err = d.GDB.Raw(`
SELECT username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? AND status = 'approved' LIMIT 1
`, orderID).Scan(&row).Error
return row.Username, row.LivreurAssign, err
}
+34
View File
@@ -0,0 +1,34 @@
package db
import (
"time"
)
type LoginHistoryEntry struct {
ID int `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
}
// RecordLivreurLogin enregistre une connexion réussie d'un livreur (best-effort, non bloquant).
func (d *Database) RecordLivreurLogin(username string) error {
return d.GDB.Exec(`
INSERT INTO login_history (username, created_at)
VALUES (?, NOW())
`, username).Error
}
// GetLivreurLoginHistoryByMonth retourne le détail des connexions d'un livreur pour un mois donné,
// triées du plus récent au plus ancien (max 50 entrées).
func (d *Database) GetLivreurLoginHistoryByMonth(username string, year, month int) ([]LoginHistoryEntry, error) {
var entries []LoginHistoryEntry
err := d.GDB.Raw(`
SELECT id, username, created_at FROM login_history
WHERE username = ?
AND EXTRACT(YEAR FROM created_at) = ?
AND EXTRACT(MONTH FROM created_at) = ?
ORDER BY created_at DESC
LIMIT 50
`, username, year, month).Scan(&entries).Error
return entries, err
}
+28 -9
View File
@@ -59,8 +59,8 @@ func validateMediaURL(url string) error {
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") { if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
return fmt.Errorf("path traversal détecté dans l'URL") return fmt.Errorf("path traversal détecté dans l'URL")
} }
if !strings.HasPrefix(url, "/uploads/") { if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") {
return fmt.Errorf("URL doit commencer par /uploads/") return fmt.Errorf("URL doit commencer par /uploads/ ou /media/")
} }
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"} dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
for _, char := range dangerousChars { for _, char := range dangerousChars {
@@ -93,6 +93,11 @@ func (d *Database) CreateMedia(media any) error {
mediaType := m.GetType() mediaType := m.GetType()
mediaURL := m.GetURL() mediaURL := m.GetURL()
mediaKey := ""
if mediaPtr, isPtr := media.(*models.Media); isPtr {
mediaKey = mediaPtr.Key
}
if err := validateProductID(productID); err != nil { if err := validateProductID(productID); err != nil {
log.Printf("❌ [CreateMedia] %v", err) log.Printf("❌ [CreateMedia] %v", err)
return err return err
@@ -106,14 +111,14 @@ func (d *Database) CreateMedia(media any) error {
return err return err
} }
if err := d.InsertMedia(m, productID, mediaURL, mediaType); err != nil { if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil {
log.Printf("❌ [InsertMedia] %v", err) log.Printf("❌ [InsertMedia] %v", err)
return err return err
} }
return nil return nil
} }
func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string) error { func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string, key string) error {
var exists bool var exists bool
if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil { if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil {
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err) log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
@@ -128,9 +133,9 @@ func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType str
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
} }
err := d.GDB.Raw(` err := d.GDB.Raw(`
INSERT INTO media (product_id, url, type, created_at) INSERT INTO media (product_id, url, type, key, created_at)
VALUES (?, ?, ?, ?) RETURNING id`, VALUES (?, ?, ?, ?, ?) RETURNING id`,
productID, mediaURL, mediaType, time.Now(), productID, mediaURL, mediaType, key, time.Now(),
).Scan(&result).Error ).Scan(&result).Error
if err != nil { if err != nil {
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err) log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
@@ -154,7 +159,7 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
var media models.Media var media models.Media
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, product_id, url, type, created_at SELECT id, product_id, url, type, key, created_at
FROM media WHERE id = ?`, mediaID).Scan(&media).Error FROM media WHERE id = ?`, mediaID).Scan(&media).Error
if err != nil { if err != nil {
log.Printf("❌ [GetMediaByID] Erreur query: %v", err) log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
@@ -169,6 +174,20 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
return &media, nil return &media, nil
} }
// GetMediaBatch charge les médias de plusieurs produits en une seule requête.
func (d *Database) GetMediaBatch(productIDs []int) map[int][]models.Media {
result := make(map[int][]models.Media, len(productIDs))
if len(productIDs) == 0 {
return result
}
var mediaList []models.Media
d.GDB.Raw(`SELECT id, product_id, url, type, key, created_at FROM media WHERE product_id IN ? ORDER BY product_id ASC, id ASC`, productIDs).Scan(&mediaList)
for _, m := range mediaList {
result[m.ProductID] = append(result[m.ProductID], m)
}
return result
}
func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) { func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID) log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
@@ -179,7 +198,7 @@ func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
var mediaList []models.Media var mediaList []models.Media
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT id, product_id, url, type, created_at SELECT id, product_id, url, type, key, created_at
FROM media WHERE product_id = ? FROM media WHERE product_id = ?
ORDER BY id ASC`, productID).Scan(&mediaList).Error ORDER BY id ASC`, productID).Scan(&mediaList).Error
if err != nil { if err != nil {
+4 -7
View File
@@ -36,7 +36,7 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
pipe := Redis.Pipeline() pipe := Redis.Pipeline()
pipe.LPush(RedisCtx, notifKey, notifJSON) pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199) pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour) pipe.Expire(RedisCtx, notifKey, time.Hour)
pipe.Exec(RedisCtx) //nolint pipe.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
@@ -54,7 +54,6 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
return nil return nil
} }
// NotifyLivreur envoie une notification in-app (Redis) à un livreur
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error { func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
notifKey := fmt.Sprintf("notifications:%s", username) notifKey := fmt.Sprintf("notifications:%s", username)
@@ -70,7 +69,7 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
pipe2 := Redis.Pipeline() pipe2 := Redis.Pipeline()
pipe2.LPush(RedisCtx, notifKey, notifJSON) pipe2.LPush(RedisCtx, notifKey, notifJSON)
pipe2.LTrim(RedisCtx, notifKey, 0, 199) pipe2.LTrim(RedisCtx, notifKey, 0, 199)
pipe2.Expire(RedisCtx, notifKey, 7*24*time.Hour) pipe2.Expire(RedisCtx, notifKey, time.Hour)
pipe2.Exec(RedisCtx) //nolint pipe2.Exec(RedisCtx) //nolint
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
@@ -88,7 +87,6 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
return nil return nil
} }
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) { func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
var users []struct { var users []struct {
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
@@ -114,7 +112,7 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
notifKey := fmt.Sprintf("notifications:%s", u.Username) notifKey := fmt.Sprintf("notifications:%s", u.Username)
pipe.LPush(RedisCtx, notifKey, notifJSON) pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199) pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour) pipe.Expire(RedisCtx, notifKey, time.Hour)
} }
pipe.Exec(RedisCtx) //nolint pipe.Exec(RedisCtx) //nolint
@@ -130,7 +128,6 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID) log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID)
} }
// NotifyAllAdminCabineAlert envoie une notification Redis à tous les admins/cabines lors d'une alerte
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) { func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
var users []struct { var users []struct {
Username string `gorm:"column:username"` Username string `gorm:"column:username"`
@@ -156,7 +153,7 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
notifKey := fmt.Sprintf("notifications:%s", u.Username) notifKey := fmt.Sprintf("notifications:%s", u.Username)
pipe.LPush(RedisCtx, notifKey, notifJSON) pipe.LPush(RedisCtx, notifKey, notifJSON)
pipe.LTrim(RedisCtx, notifKey, 0, 199) pipe.LTrim(RedisCtx, notifKey, 0, 199)
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour) pipe.Expire(RedisCtx, notifKey, time.Hour)
} }
pipe.Exec(RedisCtx) //nolint pipe.Exec(RedisCtx) //nolint
+36 -2
View File
@@ -1,8 +1,11 @@
package db package db
import ( import (
"database/sql"
"fmt" "fmt"
"gestion/models" "gestion/models"
"gorm.io/gorm"
) )
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error { func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
@@ -18,13 +21,44 @@ func (d *Database) SetClientParrain(clientUsername, parrainUsername string) erro
return nil return nil
} }
// SetClientParrainAndCredit assigne un parrain à un client et crédite le parrain
// dans une seule transaction, pour éviter un lien parrain enregistré sans le crédit associé.
func (d *Database) SetClientParrainAndCredit(clientUsername, parrainUsername string, creditAmount float64) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
result := tx.Model(&models.Client{}).
Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername).
Update("parrain", parrainUsername)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("client introuvable ou parrain déjà défini")
}
if creditAmount > 0 {
result = tx.Model(&models.Client{}).Where("username = ?", parrainUsername).
Updates(map[string]any{"referral_balance": gorm.Expr("referral_balance + ?", creditAmount)})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("parrain non trouvé")
}
}
return nil
})
}
func (d *Database) GetClientParrain(clientUsername string) (string, error) { func (d *Database) GetClientParrain(clientUsername string) (string, error) {
var parrain string var parrain sql.NullString
err := d.GDB.Table("clients"). err := d.GDB.Table("clients").
Select("parrain"). Select("parrain").
Where("username = ?", clientUsername). Where("username = ?", clientUsername).
Scan(&parrain).Error Scan(&parrain).Error
return parrain, err if err != nil {
return "", err
}
return parrain.String, nil
} }
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) { func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
+65 -28
View File
@@ -73,14 +73,21 @@ func (d *Database) CreateProduct(product any) error {
p.SetCreatedAt(result.CreatedAt) p.SetCreatedAt(result.CreatedAt)
p.SetUpdatedAt(result.UpdatedAt) p.SetUpdatedAt(result.UpdatedAt)
for i, price := range p.GetPrices() { if rawPrices := p.GetPrices(); len(rawPrices) > 0 {
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`, priceRows := make([]models.ProductPrice, len(rawPrices))
result.ID, price.Quantity, price.Price, price.ActivePrice).Error for i, price := range rawPrices {
if err != nil { priceRows[i] = models.ProductPrice{
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err) ProductID: result.ID,
Quantity: price.Quantity,
Price: price.Price,
ActivePrice: price.ActivePrice,
}
}
if err := d.GDB.Create(&priceRows).Error; err != nil {
log.Printf("❌ [DB CreateProduct] Erreur insertion prix batch: %v", err)
return fmt.Errorf("erreur insertion prix: %v", err) return fmt.Errorf("erreur insertion prix: %v", err)
} }
log.Printf("✅ [DB CreateProduct] Prix[%d] inséré: quantity=%g, price=%.2f", i, price.Quantity, price.Price) log.Printf("✅ [DB CreateProduct] %d prix insérés", len(priceRows))
} }
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID) log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID)
@@ -136,6 +143,27 @@ func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) {
return result, nil return result, nil
} }
// GetProductCategoriesByIDs retourne un map id→category pour une liste d'IDs.
func (d *Database) GetProductCategoriesByIDs(ids []int) (map[int]string, error) {
result := make(map[int]string, len(ids))
if len(ids) == 0 {
return result, nil
}
rows, err := d.GDB.Raw(`SELECT id, category FROM products WHERE id IN ?`, ids).Rows()
if err != nil {
return result, err
}
defer rows.Close()
for rows.Next() {
var id int
var category string
if err := rows.Scan(&id, &category); err == nil {
result[id] = category
}
}
return result, nil
}
func (d *Database) GetAllProducts() ([]models.Product, error) { func (d *Database) GetAllProducts() ([]models.Product, error) {
log.Println("📦 [GetAllProducts] START") log.Println("📦 [GetAllProducts] START")
@@ -149,23 +177,22 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
return nil, err return nil, err
} }
productIDs := make([]int, len(products))
for i, p := range products {
productIDs[i] = p.ID
}
allPrices := d.GetProductPricesBatch(productIDs)
allMedia := d.GetMediaBatch(productIDs)
for i := range products { for i := range products {
prices, err := d.GetProductPrices(products[i].ID) if prices, ok := allPrices[products[i].ID]; ok {
if err != nil {
log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", products[i].ID, err)
products[i].Prices = []models.ProductPrice{}
} else {
products[i].Prices = prices products[i].Prices = prices
log.Printf("✅ [GetAllProducts] Loaded %d prices for product %d", len(prices), products[i].ID)
}
media, err := d.GetMediaByProductID(products[i].ID)
if err != nil {
log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", products[i].ID, err)
products[i].Media = []models.Media{}
} else { } else {
products[i].Prices = []models.ProductPrice{}
}
if media, ok := allMedia[products[i].ID]; ok {
products[i].Media = media products[i].Media = media
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), products[i].ID) } else {
products[i].Media = []models.Media{}
} }
} }
@@ -188,14 +215,16 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err) return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
} }
catProductIDs := make([]int, len(products))
for i, p := range products {
catProductIDs[i] = p.ID
}
catPrices := d.GetProductPricesBatch(catProductIDs)
for i := range products { for i := range products {
prices, err := d.GetProductPrices(products[i].ID) if prices, ok := catPrices[products[i].ID]; ok {
if err != nil {
log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", products[i].ID, err)
products[i].Prices = []models.ProductPrice{}
} else {
products[i].Prices = prices products[i].Prices = prices
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), products[i].ID) } else {
products[i].Prices = []models.ProductPrice{}
} }
} }
@@ -215,9 +244,17 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID) d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
for _, price := range prices { if len(prices) > 0 {
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`, priceRows := make([]models.ProductPrice, len(prices))
productID, price.Quantity, price.Price, price.ActivePrice).Error; err != nil { for i, price := range prices {
priceRows[i] = models.ProductPrice{
ProductID: productID,
Quantity: price.Quantity,
Price: price.Price,
ActivePrice: price.ActivePrice,
}
}
if err := d.GDB.Create(&priceRows).Error; err != nil {
return fmt.Errorf("erreur insertion prix: %w", err) return fmt.Errorf("erreur insertion prix: %w", err)
} }
} }
+14
View File
@@ -13,6 +13,20 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
return prices, nil return prices, nil
} }
// GetProductPricesBatch charge les prix de plusieurs produits en une seule requête.
func (d *Database) GetProductPricesBatch(productIDs []int) map[int][]models.ProductPrice {
result := make(map[int][]models.ProductPrice, len(productIDs))
if len(productIDs) == 0 {
return result
}
var prices []models.ProductPrice
d.GDB.Where("product_id IN ?", productIDs).Order("product_id ASC, quantity ASC").Find(&prices)
for _, p := range prices {
result[p.ProductID] = append(result[p.ProductID], p)
}
return result
}
func (d *Database) AddActivePrice(priceID int) error { func (d *Database) AddActivePrice(priceID int) error {
result := d.GDB.Model(&models.ProductPrice{}). result := d.GDB.Model(&models.ProductPrice{}).
Where("id = ?", priceID). Where("id = ?", priceID).
+1 -1
View File
@@ -189,7 +189,7 @@ func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string)
} }
// 4. Vérifier toutes les autres queues de livreurs (au cas où) // 4. Vérifier toutes les autres queues de livreurs (au cas où)
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result() keys, _ := scanRedisKeys("queue:deliveryman:*")
for _, key := range keys { for _, key := range keys {
if len(key) > 6 && key[len(key)-6:] == ":count" { if len(key) > 6 && key[len(key)-6:] == ":count" {
continue continue
+48 -45
View File
@@ -27,25 +27,6 @@ func (d *Database) GetClientCancellationsCount(username string) (int, error) {
return result.Count, nil return result.Count, nil
} }
// IncrementClientCancellationsCount incrémente le compteur d'annulations
func (d *Database) IncrementClientCancellationsCount(username string) error {
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{
"cancellations_count": gorm.Expr("COALESCE(cancellations_count, 0) + 1"),
})
if result.Error != nil {
log.Printf("❌ [IncrementCancellations] Erreur: %v", result.Error)
return fmt.Errorf("erreur incrémentation: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
// penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné // penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné
func penaltyForCount(count int, tiers []models.PenaltyTier) int { func penaltyForCount(count int, tiers []models.PenaltyTier) int {
if len(tiers) == 0 { if len(tiers) == 0 {
@@ -64,6 +45,16 @@ func penaltyForCount(count int, tiers []models.PenaltyTier) int {
return sorted[len(sorted)-1].Amount return sorted[len(sorted)-1].Amount
} }
// penaltyTiers charge le barème de pénalités configuré, avec repli sur le barème par défaut si les settings sont indisponibles
func (d *Database) penaltyTiers(logCtx string) []models.PenaltyTier {
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [%s] Impossible de charger les settings, barème par défaut: %v", logCtx, err)
settings = DefaultSettings()
}
return settings.PenaltyTiers
}
// CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré // CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré
func (d *Database) CalculateCancellationPenalty(username string) (int, error) { func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
count, err := d.GetClientCancellationsCount(username) count, err := d.GetClientCancellationsCount(username)
@@ -71,13 +62,7 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
return 0, err return 0, err
} }
settings, err := d.GetSettings() penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
if err != nil {
log.Printf("⚠️ [CalculatePenalty] Impossible de charger les settings, barème par défaut: %v", err)
settings = DefaultSettings()
}
penalty := penaltyForCount(count, settings.PenaltyTiers)
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points", log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
username, count, penalty) username, count, penalty)
@@ -85,30 +70,48 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
return penalty, nil return penalty, nil
} }
// ApplyCancellationPenalty applique une pénalité et incrémente le compteur d'annulations // ApplyCancellationPenalty applique une pénalité (cumulative) et incrémente le compteur d'annulations.
// Verrouillée via FOR UPDATE pour éviter qu'un appel concurrent (même client, deux livraisons en parallèle)
// calcule la pénalité sur un compteur pas encore à jour, et l'amende s'additionne au lieu d'écraser
// le solde existant (cohérent avec CancelCommandAtomic pour l'annulation côté client).
func (d *Database) ApplyCancellationPenalty(username string) (int, error) { func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
penalty, err := d.CalculateCancellationPenalty(username) tiers := d.penaltyTiers("ApplyCancellationPenalty")
var penalty int
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var count int
if err := tx.Raw(`
SELECT COALESCE(cancellations_count, 0) FROM clients
WHERE username = ? FOR UPDATE`, username).Scan(&count).Error; err != nil {
return fmt.Errorf("erreur récupération compteur: %w", err)
}
penalty = penaltyForCount(count, tiers)
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
result := tx.Exec(`
UPDATE clients
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
amende = amende + ?,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, penalty, username)
if result.Error != nil {
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur application pénalité: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé")
}
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
return nil
})
if err != nil { if err != nil {
return 0, err return 0, err
} }
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
if err := d.IncrementClientCancellationsCount(username); err != nil {
return 0, err
}
result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", float64(penalty))
if result.Error != nil {
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error)
return 0, fmt.Errorf("erreur application pénalité: %w", result.Error)
}
if result.RowsAffected == 0 {
return 0, fmt.Errorf("client non trouvé")
}
log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username)
cacheKey := fmt.Sprintf("client:%s", username) cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey) Redis.Del(RedisCtx, cacheKey)
+48
View File
@@ -72,6 +72,18 @@ func DefaultSettings() models.AppSettings {
Mode: "single", Mode: "single",
CategoryRoutes: []models.CategoryRoute{}, CategoryRoutes: []models.CategoryRoute{},
}, },
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{ PostalZones: []models.PostalZone{
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}}, {Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
@@ -163,6 +175,30 @@ func (d *Database) GetSettings() (models.AppSettings, error) {
settings.Telegram2FAEnabled = row.Value == "true" settings.Telegram2FAEnabled = row.Value == "true"
case "shop_name": case "shop_name":
settings.ShopName = row.Value settings.ShopName = row.Value
case "admin_color_primary":
settings.AdminColorPrimary = row.Value
case "admin_color_secondary":
settings.AdminColorSecondary = row.Value
case "admin_color_success":
settings.AdminColorSuccess = row.Value
case "admin_color_danger":
settings.AdminColorDanger = row.Value
case "admin_color_warning":
settings.AdminColorWarning = row.Value
case "client_color_primary":
settings.ClientColorPrimary = row.Value
case "client_color_secondary":
settings.ClientColorSecondary = row.Value
case "client_color_success":
settings.ClientColorSuccess = row.Value
case "client_color_danger":
settings.ClientColorDanger = row.Value
case "client_color_warning":
settings.ClientColorWarning = row.Value
case "client_title_gradient_from":
settings.ClientTitleGradientFrom = row.Value
case "client_title_gradient_to":
settings.ClientTitleGradientTo = row.Value
} }
} }
return settings, nil return settings, nil
@@ -253,6 +289,18 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
{"delivery_mode", string(deliveryModeJSON)}, {"delivery_mode", string(deliveryModeJSON)},
{"shop_name", s.ShopName}, {"shop_name", s.ShopName},
{"contact_telegram", s.ContactTelegram}, {"contact_telegram", s.ContactTelegram},
{"admin_color_primary", s.AdminColorPrimary},
{"admin_color_secondary", s.AdminColorSecondary},
{"admin_color_success", s.AdminColorSuccess},
{"admin_color_danger", s.AdminColorDanger},
{"admin_color_warning", s.AdminColorWarning},
{"client_color_primary", s.ClientColorPrimary},
{"client_color_secondary", s.ClientColorSecondary},
{"client_color_success", s.ClientColorSuccess},
{"client_color_danger", s.ClientColorDanger},
{"client_color_warning", s.ClientColorWarning},
{"client_title_gradient_from", s.ClientTitleGradientFrom},
{"client_title_gradient_to", s.ClientTitleGradientTo},
} }
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
+449
View File
@@ -0,0 +1,449 @@
package db
import (
"gestion/models"
"time"
)
// ── Reset des sections de stats ─────────────────────────────────────────────
// ResetAdminStat enregistre (ou met à jour) la date de reset pour une section.
func (d *Database) ResetAdminStat(section string) error {
now := time.Now().UTC().Format(time.RFC3339)
upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
return d.GDB.Exec(upsert, section, now).Error
}
// ReadResetAt lit la date de reset stockée pour une clé donnée (zero value si absente).
func (d *Database) ReadResetAt(key string) time.Time {
var row struct {
Value string
}
err := d.GDB.Table("app_settings").
Select("value").
Where("key = ?", key).
Scan(&row).Error
if err != nil {
return time.Time{}
}
if row.Value != "" {
if t, err := time.Parse(time.RFC3339, row.Value); err == nil {
return t
}
}
return time.Time{}
}
// ── Construction des clauses WHERE (filtrage par reset) ────────────────────
// statusFilterClause construit "<baseStatus> [AND created_at >= ?]" et renvoie
// la clause ainsi que les arguments à binder, dans l'ordre.
func statusFilterClause(baseStatus string, resetAt time.Time) (string, []interface{}) {
if !resetAt.IsZero() {
return baseStatus + " AND created_at >= ?", []interface{}{resetAt.Format(time.RFC3339)}
}
return baseStatus, nil
}
// AdminStatsFilters regroupe les dates de reset pour chaque section, lues une
// seule fois puis transmises aux différentes requêtes.
type AdminStatsFilters struct {
ResetCommandes time.Time
ResetRevenus time.Time
ResetProduits time.Time
ResetHeures time.Time
ResetJours time.Time
ResetDoses time.Time
}
// LoadAdminStatsFilters lit toutes les dates de reset en une seule requête.
func (d *Database) LoadAdminStatsFilters() AdminStatsFilters {
keys := []string{
"stats_reset_commandes_at",
"stats_reset_revenus_at",
"stats_reset_produits_at",
"stats_reset_heures_at",
"stats_reset_jours_at",
"stats_reset_doses_at",
}
var rows []struct {
Key string `gorm:"column:key"`
Value string `gorm:"column:value"`
}
d.GDB.Table("app_settings").Select("key, value").Where("key IN ?", keys).Scan(&rows)
m := make(map[string]time.Time, len(keys))
for _, r := range rows {
if t, err := time.Parse(time.RFC3339, r.Value); err == nil {
m[r.Key] = t
}
}
return AdminStatsFilters{
ResetCommandes: m["stats_reset_commandes_at"],
ResetRevenus: m["stats_reset_revenus_at"],
ResetProduits: m["stats_reset_produits_at"],
ResetHeures: m["stats_reset_heures_at"],
ResetJours: m["stats_reset_jours_at"],
ResetDoses: m["stats_reset_doses_at"],
}
}
// ── Commandes par jour de la semaine (non annulées) ─────────────────────────
func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt)
query := `
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
FROM commandes
WHERE ` + where + `
GROUP BY dow
ORDER BY dow
`
return d.GDB.Raw(query, args...).Scan(wdRows).Error
}
// ── Commandes par jour sur 30 jours ──────────────────────────────────────────
func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt)
query := `
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND ` + where + `
GROUP BY DATE(created_at)
ORDER BY day
`
return d.GDB.Raw(query, args...).Scan(dayRows).Error
}
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────────
func (d *Database) RevenueByDayLast30(dayRevRows *[]models.DayRevenueRow, resetAt time.Time) error {
where, args := statusFilterClause("status = 'approved'", resetAt)
query := `
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND ` + where + `
GROUP BY DATE(created_at)
ORDER BY day
`
return d.GDB.Raw(query, args...).Scan(dayRevRows).Error
}
// ── Commandes par jour sur un mois calendaire complet ────────────────────────
type DailyMonthStatRow struct {
Day time.Time
Count int
Revenue float64
Quantity float64
}
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetAt time.Time) error {
start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
end := start.AddDate(0, 1, 0)
where, whereArgs := statusFilterClause("status != 'cancelled'", resetAt)
query := `
SELECT
d.day,
COALESCE(d.count, 0) AS count,
COALESCE(rv.revenue, 0) AS revenue,
COALESCE(qt.quantity, 0) AS quantity
FROM (
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= ? AND created_at < ?
AND ` + where + `
GROUP BY DATE(created_at)
) d
LEFT JOIN (
SELECT DATE(created_at) AS day,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= ? AND created_at < ?
AND status = 'approved'
GROUP BY DATE(created_at)
) rv ON rv.day = d.day
LEFT JOIN (
SELECT DATE(c.created_at) AS day, SUM(ci.quantite) AS quantity
FROM commandes c
JOIN command_items ci ON ci.command_id = c.id
WHERE c.created_at >= ? AND c.created_at < ?
AND c.status != 'cancelled'
GROUP BY DATE(c.created_at)
) qt ON qt.day = d.day
ORDER BY d.day
`
// Ordre des "?" dans la requête : (start, end, [reset]) pour le bloc "d",
// puis (start, end) pour "rv", puis (start, end) pour "qt".
args := []interface{}{start, end}
args = append(args, whereArgs...)
args = append(args, start, end)
args = append(args, start, end)
return d.GDB.Raw(query, args...).Scan(rows).Error
}
// OrdersAndRevenueByHour renvoie, par heure, le nombre de commandes non annulées
// (volume d'activité) et le revenu confirmé (commandes approuvées uniquement —
// cohérent avec TotalRevenue/RevenueByDayLast30, pour ne pas compter comme
// "revenu" une commande encore en cours qui pourrait être annulée).
func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt time.Time) error {
where, args := statusFilterClause("status != 'cancelled'", resetAt)
query := `
SELECT
EXTRACT(HOUR FROM created_at)::int AS hour,
COUNT(*) AS count,
COALESCE(SUM(CASE WHEN status = 'approved' THEN total_prix - COALESCE(referral_used, 0) ELSE 0 END), 0) AS revenue
FROM commandes
WHERE ` + where + `
GROUP BY hour
ORDER BY hour
`
return d.GDB.Raw(query, args...).Scan(hourRows).Error
}
// ── Top produits (quantité vendue) ───────────────────────────────────────────
// TopProducts renvoie les produits les plus commandés. La quantité/le nombre de
// commandes reflètent l'activité (non annulées), le revenu ne compte que les
// commandes approuvées (revenu confirmé, cohérent avec le résumé global).
func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time, limit int) error {
where, args := statusFilterClause("c.status != 'cancelled'", resetAt)
args = append(args, limit)
query := `
SELECT
ci.product_id,
ci.produit AS name,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue,
COALESCE(p.category, '') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE ` + where + `
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY total_quantity DESC
LIMIT ?
`
return d.GDB.Raw(query, args...).Scan(prodRows).Error
}
// ── Répartition des doses/quantités par produit ──────────────────────────────
// QuantityBreakdown : quantité/nombre de commandes reflètent l'activité (non
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, resetAt time.Time) error {
where, args := statusFilterClause("c.status != 'cancelled'", resetAt)
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
ci.quantite AS quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(ci.quantite) AS total_sold,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE ` + where + `
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
`
return d.GDB.Raw(query, args...).Scan(qtyRows).Error
}
// ── Détail du jour (catégorie → produits) ────────────────────────────────────
// DailyProductDetail : quantité/nombre de commandes reflètent l'activité (non
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
func (d *Database) DailyProductDetail(dailyRows *[]models.DailyProductRow) error {
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
COALESCE(p.category, 'Sans catégorie') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE DATE(c.created_at) = CURRENT_DATE
AND c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY p.category, SUM(ci.quantite) DESC
`
return d.GDB.Raw(query).Scan(dailyRows).Error
}
func (d *Database) DailyProductDetailForDate(dailyRows *[]models.DailyProductRow, date time.Time) error {
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
end := start.AddDate(0, 0, 1)
query := `
SELECT
ci.product_id,
ci.produit AS product_name,
COALESCE(p.category, 'Sans catégorie') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(CASE WHEN c.status = 'approved'
THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0)
ELSE 0 END) AS revenue
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE c.created_at >= ? AND c.created_at < ?
AND c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY p.category, SUM(ci.quantite) DESC
`
return d.GDB.Raw(query, start, end).Scan(dailyRows).Error
}
// DailyOrdersCountForDate renvoie le nombre de commandes (non annulées) pour
// une date précise.
func (d *Database) DailyOrdersCountForDate(date time.Time) (int64, error) {
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
end := start.AddDate(0, 0, 1)
var count int64
err := d.GDB.Raw(`
SELECT COUNT(DISTINCT id) FROM commandes
WHERE created_at >= ? AND created_at < ? AND status != 'cancelled'
`, start, end).Scan(&count).Error
return count, err
}
// DailyOrdersCount renvoie le nombre de commandes (non annulées) du jour.
func (d *Database) DailyOrdersCount() (int64, error) {
var count int64
err := d.GDB.Raw(`
SELECT COUNT(DISTINCT id) FROM commandes
WHERE DATE(created_at) = CURRENT_DATE AND status != 'cancelled'
`).Scan(&count).Error
return count, err
}
// ── Résumé global ────────────────────────────────────────────────────────────
// TotalOrders renvoie le nombre total de commandes filtré par le reset "commandes".
func (d *Database) TotalOrders(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt)
var total int64
err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error
return total, err
}
// TotalRevenue renvoie le revenu total (commandes approuvées) filtré par le reset "revenus".
func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
where, args := statusFilterClause("status = 'approved'", resetAt)
var total float64
err := d.GDB.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE `+where, args...).
Scan(&total).Error
return total, 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)
var activeDays int64
query := `
SELECT COUNT(DISTINCT DATE(created_at))
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
err := d.GDB.Raw(query, args...).Scan(&activeDays).Error
return activeDays, err
}
// OrdersCountLast30 renvoie le nombre de commandes sur les 30 derniers jours.
func (d *Database) OrdersCountLast30(resetAt time.Time) (int64, error) {
where, args := statusFilterClause("status != 'cancelled'", resetAt)
var count int64
query := `
SELECT COUNT(*) FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where
err := d.GDB.Raw(query, args...).Scan(&count).Error
return count, err
}
func (d *Database) GetMyDeliveryStatsPerDay(statsRows *[]models.DayRowWithResult, username string) error {
query := `
SELECT DATE(updated_at) AS day,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(updated_at)
ORDER BY day
`
return d.GDB.Raw(query, username).Scan(statsRows).Error
}
func (d *Database) GetMyDeliveryStatsPerWeek(statsRow *[]models.WeekRow, username string) error {
query := `
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num,
EXTRACT(YEAR FROM updated_at)::int AS year,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 weeks'
GROUP BY week_num, year
ORDER BY year, week_num
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
func (d *Database) GetMyDeliveryStatsPerMonth(statsRow *[]models.MonthRow, username string) error {
query := `
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num,
EXTRACT(YEAR FROM updated_at)::int AS year,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 months'
GROUP BY month_num, year
ORDER BY year, month_num
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
func (d *Database) GetMyDeliveryStatsToday(statsRow *models.TodayRow, username string) error {
query := `
SELECT COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND DATE(updated_at) = CURRENT_DATE
`
return d.GDB.Raw(query, username).Scan(statsRow).Error
}
+1 -1
View File
@@ -43,7 +43,7 @@ func GenerateLinkToken(username, role string) (string, error) {
key := fmt.Sprintf("telegram:link:%s", token) key := fmt.Sprintf("telegram:link:%s", token)
if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil { if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil {
return "", fmt.Errorf("Redis SET: %w", err) return "", fmt.Errorf("redis set: %w", err)
} }
return token, nil return token, nil
} }
+2 -2
View File
@@ -15,7 +15,7 @@ func (d *Database) CreateUser(user *models.User) error {
func (d *Database) GetAllUsers() ([]*models.User, error) { func (d *Database) GetAllUsers() ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := d.GDB.Order("created_at DESC").Find(&users).Error; err != nil { if err := d.GDB.Order("created_at DESC").Limit(500).Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err) return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
} }
return users, nil return users, nil
@@ -23,7 +23,7 @@ func (d *Database) GetAllUsers() ([]*models.User, error) {
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) { func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
var users []*models.User var users []*models.User
if err := d.GDB.Where("role = ?", "livreur").Find(&users).Error; err != nil { if err := d.GDB.Where("role = ?", "livreur").Limit(100).Find(&users).Error; err != nil {
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err) return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
} }
return users, nil return users, nil
@@ -10,7 +10,7 @@ import (
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter // FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
func (d *Database) FindLeastLoadedDeliveryman() (string, error) { func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result() keys, err := scanRedisKeys("delivery:status:*")
if err != nil || len(keys) == 0 { if err != nil || len(keys) == 0 {
return "", fmt.Errorf("aucun livreur trouvé") return "", fmt.Errorf("aucun livreur trouvé")
} }
@@ -165,6 +165,7 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i
eta := map[string]any{ eta := map[string]any{
"command_id": commandID, "command_id": commandID,
"total_eta_minutes": totalETA, "total_eta_minutes": totalETA,
"eta_minutes": totalETA,
"queue_position": queuePosition, "queue_position": queuePosition,
"updated_at": now.Unix(), "updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(), "arrival_time": arrivalTime.Unix(),
@@ -9,7 +9,6 @@ import (
"time" "time"
) )
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
func (d *Database) CleanupInvalidQueueCommands() (int, error) { func (d *Database) CleanupInvalidQueueCommands() (int, error) {
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...") log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
@@ -82,7 +81,6 @@ func (d *Database) CleanupInvalidQueueCommands() (int, error) {
return removedCount, nil return removedCount, nil
} }
// removeInvalidCommand supprime une commande invalide de toutes les queues
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) { func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
commandIDStr := fmt.Sprintf("%d", commandID) commandIDStr := fmt.Sprintf("%d", commandID)
-1
View File
@@ -91,7 +91,6 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
return overview, nil return overview, nil
} }
// GetQueueStats - Statistiques détaillées
func (d *Database) GetQueueStats() (map[string]any, error) { func (d *Database) GetQueueStats() (map[string]any, error) {
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result() normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result() priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
@@ -142,7 +142,6 @@ func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
return nil return nil
} }
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
func (d *Database) RemoveCommandFromQueue(commandID int) error { func (d *Database) RemoveCommandFromQueue(commandID int) error {
key := fmt.Sprintf("queue:pending:%d", commandID) key := fmt.Sprintf("queue:pending:%d", commandID)
commandIDStr := strconv.Itoa(commandID) commandIDStr := strconv.Itoa(commandID)
@@ -321,7 +320,6 @@ func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) er
return nil return nil
} }
// scanRedisKeys remplace KEYS * par SCAN pour ne pas bloquer Redis.
func scanRedisKeys(pattern string) ([]string, error) { func scanRedisKeys(pattern string) ([]string, error) {
var all []string var all []string
cursor := uint64(0) cursor := uint64(0)
+18
View File
@@ -19,6 +19,24 @@ require (
) )
require ( require (
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/aws/smithy-go v1.27.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
+36
View File
@@ -1,3 +1,39 @@
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8=
github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+28 -1
View File
@@ -27,7 +27,6 @@ func AlertPolice(c *gin.Context) {
var req struct { var req struct {
Message string `json:"message"` Message string `json:"message"`
} }
// message optionnel — on ignore l'erreur de bind
_ = c.ShouldBindJSON(&req) _ = c.ShouldBindJSON(&req)
usernameStr := username.(string) usernameStr := username.(string)
@@ -61,6 +60,25 @@ func DeleteAlert(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return return
} }
// Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet.
if userRole == "livreur" {
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
alert, err := database.GetAlertPolicy(alertID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"})
return
}
if alert.Username != username.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
}
if err = database.DeleteAlertPolicy(alertID); err != nil { if err = database.DeleteAlertPolicy(alertID); err != nil {
utils.ServerErr(c, "Impossible de supprimer l'alerte", err) utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
return return
@@ -92,6 +110,15 @@ func GetAlert(c *gin.Context) {
return return
} }
// Un livreur ne peut consulter que ses propres alertes — admin/cabine gardent l'accès complet pour le dispatch
if userRole == "livreur" {
username, exists := c.Get("username")
if !exists || alert.Username != username.(string) {
c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"})
return
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"alert": alert, "alert": alert,
+14 -164
View File
@@ -55,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) {
claims := models.AdminClaims{ claims := models.AdminClaims{
UserID: user.ID, UserID: user.ID,
Username: user.Username, Username: user.Username,
Role: user.Role, // ← "admin" ou "cabine" ou "livreur" Role: user.Role,
SessionID: sessionID, SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "api-admin", // Même issuer pour tous les admins Issuer: "api-admin",
Subject: strconv.Itoa(user.ID), Subject: strconv.Itoa(user.ID),
}, },
} }
@@ -73,112 +73,6 @@ func generateAdminToken(user *models.User) (string, error) {
return tokenString, nil return tokenString, nil
} }
// RegisterClient crée un nouveau compte client
func RegisterClient(c *gin.Context) {
var req models.RegisterClientRequest
if err := c.ShouldBindJSON(&req); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
})
return
}
// Sanitize text inputs
req.Username = utils.StripHTML(req.Username)
req.Nom = utils.StripHTML(req.Nom)
req.Prenom = utils.StripHTML(req.Prenom)
// Validation téléphone
if !utils.ValidatePhoneNumber(req.Telephone) {
log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Numéro de téléphone invalide",
})
return
}
normalizedPhone := utils.NormalizePhoneNumber(req.Telephone)
database := c.MustGet("database").(*db.Database)
// Vérifier username unique
if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil {
log.Printf("❌ [REGISTER_CLIENT] Username déjà utilisé: %s", req.Username)
c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"})
return
}
// Vérifier téléphone unique
if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil {
log.Printf("❌ [REGISTER_CLIENT] Téléphone déjà utilisé: %s", normalizedPhone)
c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"})
return
}
// Hasher le mot de passe
hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur bcrypt: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"})
return
}
// Créer le client
client := &models.Client{
Username: req.Username,
Password: string(hashed),
Nom: strings.TrimSpace(req.Nom),
Prenom: strings.TrimSpace(req.Prenom),
Telephone: normalizedPhone,
CreatedAt: time.Now(),
}
if err := database.CreateClient(client); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur création: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"})
return
}
// Générer le token
token, err := generateClientToken(client)
if err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur génération token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"})
return
}
// Sauvegarder le token
expiresAt := time.Now().Add(clientTokenDuration)
if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil {
log.Printf("❌ [REGISTER_CLIENT] Erreur SaveToken: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"})
return
}
// Créer la session Redis
sessionID := uuid.New().String()
if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil {
log.Printf("⚠️ [REGISTER_CLIENT] Erreur session Redis: %v", err)
}
client.Password = ""
c.JSON(http.StatusCreated, models.LoginResponse{
AccessToken: token,
TokenType: "Bearer",
ExpiresIn: int(clientTokenDuration.Seconds()),
User: gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"role": "client",
"session_id": sessionID,
},
})
}
// AdminCreateClient crée un client depuis l'interface admin (sans session ni token) // AdminCreateClient crée un client depuis l'interface admin (sans session ni token)
func AdminCreateClient(c *gin.Context) { func AdminCreateClient(c *gin.Context) {
if userRole := c.GetString("role"); userRole != "admin" { if userRole := c.GetString("role"); userRole != "admin" {
@@ -561,6 +455,12 @@ func LoginAdmin(c *gin.Context) {
return return
} }
if user.Role == "livreur" {
if err := database.RecordLivreurLogin(user.Username); err != nil {
log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err)
}
}
token, _ := generateAdminToken(user) token, _ := generateAdminToken(user)
expiresAt := time.Now().Add(adminTokenDuration) expiresAt := time.Now().Add(adminTokenDuration)
@@ -602,62 +502,6 @@ func LogoutAdmin(c *gin.Context) {
// HELPERS // HELPERS
// ============================================ // ============================================
// GetCurrentClient récupère le client actuel
// GET /api/v1/profile/client
func GetCurrentClient(c *gin.Context) {
clientID := c.GetInt("client_id")
database := c.MustGet("database").(*db.Database)
client, err := database.GetClientByID(clientID)
if err != nil {
log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID)
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
client.Password = ""
log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username)
c.JSON(http.StatusOK, gin.H{
"client": gin.H{
"id": client.ID,
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"command": client.Command,
"amende": client.Amende,
"points_extra": client.PointsExtra,
},
})
}
// GetCurrentAdmin récupère l'admin/user actuel
// GET /api/v1/profile/admin
func GetCurrentAdmin(c *gin.Context) {
userID := c.GetInt("user_id")
database := c.MustGet("database").(*db.Database)
user, err := database.GetUserByID(userID)
if err != nil {
log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID)
c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"})
return
}
user.Password = ""
log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username)
c.JSON(http.StatusOK, gin.H{
"user": models.ProfileResponse{
Username: user.Username,
Role: user.Role,
},
})
}
// GetAllUsers récupère tous les utilisateurs (Admin only) // GetAllUsers récupère tous les utilisateurs (Admin only)
func GetAllUsers(c *gin.Context) { func GetAllUsers(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -855,3 +699,9 @@ func CreateUser(c *gin.Context) {
log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role) log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role)
c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"}) c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"})
} }
func Health(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
})
}
@@ -195,7 +195,6 @@ func CancelCommandByClient(c *gin.Context) {
return return
} }
// ✅ AUTRES ERREURS
switch err.Error() { switch err.Error() {
case "commande non trouvée": case "commande non trouvée":
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"}) c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
@@ -312,7 +311,6 @@ func GetAllCancelledOrders(c *gin.Context) {
return return
} }
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
var enrichedOrders []map[string]any var enrichedOrders []map[string]any
for _, order := range cancelledOrders { for _, order := range cancelledOrders {
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"])) orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
+20
View File
@@ -109,6 +109,26 @@ func UpdateCategory(c *gin.Context) {
}) })
} }
func ReorderCategories(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
IDs []int `json:"ids" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Liste d'IDs requise"})
return
}
if err := database.ReorderCategories(req.IDs); err != nil {
log.Printf("❌ [CATEGORIES] Reorder erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du réordonnancement"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func DeleteCategory(c *gin.Context) { func DeleteCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
+1 -1
View File
@@ -209,7 +209,7 @@ func buildTimeline(logs []map[string]any) []gin.H {
for _, logEntry := range logs { for _, logEntry := range logs {
status, _ := logEntry["status"].(string) status, _ := logEntry["status"].(string)
message, _ := logEntry["message"].(string) message, _ := logEntry["message"].(string)
createdAt, _ := logEntry["created_at"] createdAt := logEntry["created_at"]
timeline = append(timeline, gin.H{ timeline = append(timeline, gin.H{
"status": status, "status": status,
+4 -16
View File
@@ -482,10 +482,6 @@ func StaffApproveDelivery(c *gin.Context) {
}) })
} }
// ============================================
// APPROBATION PAR ADMIN
// ============================================
func ValidateDelivery(c *gin.Context) { func ValidateDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -1145,19 +1141,11 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
} }
if req.Status == "cancelled" { if req.Status == "cancelled" {
current, errCmd := database.GetCommandByID(commandID) if err := database.CancelCommandByAdminAtomic(commandID); err != nil {
if errCmd == nil { utils.ServerErr(c, "Impossible d'annuler la commande", err)
currentStatus, _ := current["status"].(string) return
alreadyDone := currentStatus == "cancelled" || currentStatus == "approved" || currentStatus == "livre"
if !alreadyDone {
if err := database.RestoreCommandStock(commandID); err != nil {
log.Printf("⚠️ [STATUS_ADMIN] Erreur restauration stock cmd %d: %v", commandID, err)
}
}
} }
} } else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
utils.ServerErr(c, "Impossible de mettre à jour le statut", err) utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
return return
} }
+4 -3
View File
@@ -12,11 +12,12 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// IPNWebhook - POST /api/v1/webhook/nowpayments // IPNWebhook - POST /api/v1/webhooks/nowpayments
func IPNWebhook(c *gin.Context) { func IPNWebhook(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient) npRaw, npExists := c.Get("nowpayments")
if !ok || np == nil { np, ok := npRaw.(*services.NowPaymentsClient)
if !npExists || !ok || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
return return
} }
+70 -80
View File
@@ -4,13 +4,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/models"
"gestion/services" "gestion/services"
"gestion/utils" "gestion/utils"
"log" "log"
"net/http" "net/http"
"slices" "slices"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -40,14 +40,27 @@ func GetMyDeliveries(c *gin.Context) {
return return
} }
// Collecter tous les IDs et usernames en une passe pour éviter les N+1
commandIDs := make([]int, 0, len(commands))
clientUsernames := make([]string, 0, len(commands))
for _, cmd := range commands {
if cid, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])); cid > 0 {
commandIDs = append(commandIDs, cid)
}
if u, _ := cmd["username"].(string); u != "" {
clientUsernames = append(clientUsernames, u)
}
}
allItems, _ := database.GetCommandItemsBatch(commandIDs)
allClients, _ := database.GetClientsByUsernames(clientUsernames)
filteredCommands := make([]gin.H, len(commands)) filteredCommands := make([]gin.H, len(commands))
for i, cmd := range commands { for i, cmd := range commands {
commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])) commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"]))
items, _ := database.GetCommandItems(commandID) items := allItems[commandID]
// Client info SANS téléphone
clientUsername, _ := cmd["username"].(string) clientUsername, _ := cmd["username"].(string)
client, _ := database.GetClientByUsername(clientUsername) client := allClients[clientUsername]
clientInfo := gin.H{"nom": "Client", "prenom": ""} clientInfo := gin.H{"nom": "Client", "prenom": ""}
if client != nil { if client != nil {
@@ -244,7 +257,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon) distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [GPS] Distance: %.2f m", distance) log.Printf("📍 [GPS] Distance: %.2f m", distance)
if distance > 100 { if distance > 350 {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": "Vous êtes trop loin de la destination", "error": "Vous êtes trop loin de la destination",
"current_distance": fmt.Sprintf("%.2f", distance), "current_distance": fmt.Sprintf("%.2f", distance),
@@ -259,22 +272,34 @@ func UpdateDeliveryStatus(c *gin.Context) {
} }
} }
// Mettre à jour le statut // Mettre à jour le statut.
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil { // Le cas "cancelled" passe par une transaction atomique dédiée (transition +
c.JSON(http.StatusInternalServerError, gin.H{ // remboursement stock), pour empêcher tout double remboursement en cas de
"error": "Erreur mise à jour", // double appel (double-tap, retry réseau, commande déjà annulée ailleurs).
})
return
}
if req.Status == "cancelled" { if req.Status == "cancelled" {
alreadyCancelled, prevStatus, cancelErr := database.CancelDeliveryByLivreurAtomic(commandID)
if cancelErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
})
return
}
if alreadyCancelled {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Commande déjà annulée",
"command_id": commandID,
"status": "cancelled",
})
return
}
cancelMsg := req.Notes cancelMsg := req.Notes
if cancelMsg == "" { if cancelMsg == "" {
cancelMsg = "Annulé par le livreur" cancelMsg = "Annulé par le livreur"
} }
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg)) database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
prevStatus, _ := command["status"].(string)
if prevStatus == "arrived" || prevStatus == "livre" { if prevStatus == "arrived" || prevStatus == "livre" {
clientUsername, _ := command["username"].(string) clientUsername, _ := command["username"].(string)
if clientUsername != "" { if clientUsername != "" {
@@ -285,6 +310,11 @@ func UpdateDeliveryStatus(c *gin.Context) {
} }
} }
} }
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour",
})
return
} }
// ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA // ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA
@@ -441,12 +471,8 @@ func UpdateDeliveryStatus(c *gin.Context) {
database.CompleteDeliveryAndProcessNext(usernameStr, commandID) database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "cancelled": case "cancelled":
// Transition + remboursement stock déjà effectués atomiquement plus haut.
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID) log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
if err := database.RestoreCommandStock(commandID); err != nil {
log.Printf("⚠️ [STATUS_LIVREUR] Erreur restauration stock cmd %d: %v", commandID, err)
} else {
log.Printf("✅ [STATUS_LIVREUR] Stock restauré pour cmd %d", commandID)
}
database.CompleteDeliveryAndProcessNext(usernameStr, commandID) database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "arrived": case "arrived":
@@ -526,10 +552,8 @@ func ReportDeliveryIssue(c *gin.Context) {
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue}) c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
} }
// GET /api/v1/livreur/stats
func GetMyDeliveryStats(c *gin.Context) { func GetMyDeliveryStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username") username, exists := c.Get("username")
if !exists { if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
@@ -540,66 +564,30 @@ func GetMyDeliveryStats(c *gin.Context) {
return return
} }
usernameStr := username.(string) usernameStr := username.(string)
gdb := database.GDB
type DayRow struct { var dayRows []models.DayRowWithResult
Day time.Time `gorm:"column:day"` if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil {
Count int `gorm:"column:count"` c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"})
Revenue float64 `gorm:"column:revenue"` return
}
type WeekRow struct {
WeekNum int `gorm:"column:week_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type MonthRow struct {
MonthNum int `gorm:"column:month_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
} }
var dayRows []DayRow var weekRows []models.WeekRow
gdb.Raw(` if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil {
SELECT DATE(updated_at) AS day, c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"})
COUNT(*) AS count, return
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue }
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '30 days'
GROUP BY DATE(updated_at)
ORDER BY day
`, usernameStr).Scan(&dayRows)
var weekRows []WeekRow var monthRows []models.MonthRow
gdb.Raw(` if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil {
SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num, c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"})
EXTRACT(YEAR FROM updated_at)::int AS year, return
COUNT(*) AS count, }
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 weeks'
GROUP BY week_num, year
ORDER BY year, week_num
`, usernameStr).Scan(&weekRows)
var monthRows []MonthRow var todayRow models.TodayRow
gdb.Raw(` if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil {
SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num, c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"})
EXTRACT(YEAR FROM updated_at)::int AS year, return
COUNT(*) AS count, }
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE livreur_assign = ?
AND status IN ('livre', 'approved')
AND updated_at >= NOW() - INTERVAL '12 months'
GROUP BY month_num, year
ORDER BY year, month_num
`, usernameStr).Scan(&monthRows)
monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"} monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"}
@@ -635,9 +623,11 @@ func GetMyDeliveryStats(c *gin.Context) {
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"by_day": byDay, "by_day": byDay,
"by_week": byWeek, "by_week": byWeek,
"by_month": byMonth, "by_month": byMonth,
"today_count": todayRow.Count,
"today_revenue": todayRow.Revenue,
}) })
} }
+2 -3
View File
@@ -22,7 +22,6 @@ import (
func GetDeliveryPersonDetails(c *gin.Context) { func GetDeliveryPersonDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Admin seulement
userRole := c.GetString("role") userRole := c.GetString("role")
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" { if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole) log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole)
@@ -63,9 +62,9 @@ func GetDeliveryPersonDetails(c *gin.Context) {
// Utiliser la fonction GPS existante // Utiliser la fonction GPS existante
lat, lon, err := database.GetDeliveryPersonLocation(username) lat, lon, err := database.GetDeliveryPersonLocation(username)
var locationInfo map[string]interface{} var locationInfo map[string]any
if err == nil { if err == nil {
locationInfo = map[string]interface{}{ locationInfo = map[string]any{
"latitude": lat, "latitude": lat,
"longitude": lon, "longitude": lon,
} }
+1 -15
View File
@@ -85,7 +85,6 @@ func GetOrderETA(c *gin.Context) {
return return
} }
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
cmdUsername, _ := command["username"].(string) cmdUsername, _ := command["username"].(string)
userRole := c.GetString("role") userRole := c.GetString("role")
@@ -112,10 +111,8 @@ func GetOrderETA(c *gin.Context) {
} }
} }
// 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE
cmdStatus, _ := command["status"].(string) cmdStatus, _ := command["status"].(string)
// ✅ CORRECTION: Vérifier si commande terminée
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" { if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus) log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -129,7 +126,6 @@ func GetOrderETA(c *gin.Context) {
return return
} }
// Pour pending/assigned: pas encore de position livreur disponible
if cmdStatus == "pending" || cmdStatus == "assigned" { if cmdStatus == "pending" || cmdStatus == "assigned" {
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus) log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -142,7 +138,6 @@ func GetOrderETA(c *gin.Context) {
return return
} }
// Pour arrived: livreur sur place, ETA non pertinent
if cmdStatus == "arrived" { if cmdStatus == "arrived" {
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place") log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -154,9 +149,7 @@ func GetOrderETA(c *gin.Context) {
}) })
return return
} }
// Pour en_route: calcul ETA réel via position du livreur
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
etaKey := fmt.Sprintf("command:eta:%d", commandID) etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result() etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
@@ -197,10 +190,8 @@ func GetOrderETA(c *gin.Context) {
} }
} }
// 7️⃣ Pas de cache valide - Recalculer l'ETA
log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...") log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...")
// Récupérer coordonnées destination
var destLat, destLon float64 var destLat, destLon float64
destCacheKey := fmt.Sprintf("command:destination:%d", commandID) destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
@@ -248,13 +239,10 @@ func GetOrderETA(c *gin.Context) {
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon} toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
// Cas 1 : GPS livreur disponible
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign) livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
if gpsErr != nil { if gpsErr != nil {
// Cas 2 : GPS absent → dernière adresse de livraison
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign) lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
if lastErr != nil || lastLat == 0 { if lastErr != nil || lastLat == 0 {
// Cas 3 : Aucune position → cache périmé ou message
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign) log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData)) c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
return return
@@ -263,7 +251,6 @@ func GetOrderETA(c *gin.Context) {
log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon) log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon)
} }
// Calculer ETA avec TomTom
log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)", log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)",
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude) livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
@@ -274,11 +261,10 @@ func GetOrderETA(c *gin.Context) {
etaMinutes = services.CalculateETA(distanceKm) etaMinutes = services.CalculateETA(distanceKm)
} }
// Sauvegarder en cache
now := time.Now() now := time.Now()
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute) arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
etaCache := map[string]interface{}{ etaCache := map[string]any{
"command_id": commandID, "command_id": commandID,
"eta_minutes": etaMinutes, "eta_minutes": etaMinutes,
"updated_at": now.Unix(), "updated_at": now.Unix(),
+5 -45
View File
@@ -169,10 +169,6 @@ func FindNearestDeliveryPerson(c *gin.Context) {
}) })
} }
// ============================================
// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE
// ============================================
// GetAllDeliveryDistances retourne tous les livreurs triés par distance // GetAllDeliveryDistances retourne tous les livreurs triés par distance
func GetAllDeliveryDistances(c *gin.Context) { func GetAllDeliveryDistances(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -258,9 +254,6 @@ func GetAllDeliveryDistances(c *gin.Context) {
}) })
} }
// ============================================
// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES
// ============================================
func AutoAssignNearestDeliveryPerson(c *gin.Context) { func AutoAssignNearestDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService) geoService := c.MustGet("geoService").(*services.GeoService)
@@ -347,12 +340,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("🚗 %d livreur(s) actif(s)", activeCount) log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
// Récupérer les livreurs actifs avec capacité disponible
activeLivreurs, err := database.GetAllActiveDeliveryPersons() activeLivreurs, err := database.GetAllActiveDeliveryPersons()
// Si aucun livreur avec capacité disponible
if err != nil || len(activeLivreurs) == 0 { if err != nil || len(activeLivreurs) == 0 {
// Cas 1: Un seul livreur actif -> pas de limite
if activeCount == 1 { if activeCount == 1 {
singleDeliveryman, err := database.GetSingleActiveDeliveryman() singleDeliveryman, err := database.GetSingleActiveDeliveryman()
if err != nil { if err != nil {
@@ -371,7 +361,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// ✅ Passer les coordonnées à la fonction d'assignation
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address) err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -386,7 +375,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance) log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance)
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Commande assignée au seul livreur actif (sans limite)", "message": "Commande assignée au seul livreur actif (sans limite)",
@@ -399,7 +387,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"single_driver": true, "single_driver": true,
"traffic_aware": true, "traffic_aware": true,
}, },
"eta": etaData, // ✅ Directement l'objet complet "eta": etaData,
"delivery_address": address, "delivery_address": address,
"coordinates": gin.H{ "coordinates": gin.H{
"latitude": location.Latitude, "latitude": location.Latitude,
@@ -410,13 +398,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity() allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
if allAtCapacity && numActive > 1 { if allAtCapacity && numActive > 1 {
log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive) log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive)
// Trouver le livreur le moins chargé (même s'il dépasse 10)
leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced() leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced()
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -424,8 +410,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
}) })
return return
} }
// Calculer le temps de trajet avec TomTom
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude) travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -433,8 +417,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
}) })
return return
} }
// ✅ Assigner de force avec coordonnées
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address) err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -449,7 +431,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance) log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Commande assignée par distribution forcée (capacité max dépassée)", "message": "Commande assignée par distribution forcée (capacité max dépassée)",
@@ -463,7 +444,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"over_capacity": true, "over_capacity": true,
"traffic_aware": true, "traffic_aware": true,
}, },
"eta": etaData, // ✅ Directement l'objet complet "eta": etaData,
"delivery_address": address, "delivery_address": address,
"coordinates": gin.H{ "coordinates": gin.H{
"latitude": location.Latitude, "latitude": location.Latitude,
@@ -474,7 +455,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// Cas 3: Erreur générique
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"error": "Aucun livreur actif avec capacité disponible", "error": "Aucun livreur actif avec capacité disponible",
"active_count": activeCount, "active_count": activeCount,
@@ -483,13 +463,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// Cas normal: Au moins un livreur avec capacité disponible
usernames := make([]string, len(activeLivreurs)) usernames := make([]string, len(activeLivreurs))
for i, livreur := range activeLivreurs { for i, livreur := range activeLivreurs {
usernames[i] = livreur.Username usernames[i] = livreur.Username
} }
// Trouver le livreur le plus proche (calcul rapide)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames) nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -498,7 +476,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
return return
} }
// Recalculer l'ETA avec TomTom pour plus de précision
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords) travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
if err != nil { if err != nil {
// Fallback sur le calcul initial // Fallback sur le calcul initial
@@ -509,7 +486,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime) log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime)
// ✅ Assigner à la queue du livreur avec coordonnées
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address) err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -524,7 +500,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username) log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username)
// ✅ CORRECTION: Utiliser etaData directement
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Commande assignée à la queue du livreur", "message": "Commande assignée à la queue du livreur",
@@ -537,7 +512,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
"single_driver": activeCount == 1, "single_driver": activeCount == 1,
"traffic_aware": err == nil, "traffic_aware": err == nil,
}, },
"eta": etaData, // ✅ Directement l'objet complet "eta": etaData,
"delivery_address": address, "delivery_address": address,
"coordinates": gin.H{ "coordinates": gin.H{
"latitude": location.Latitude, "latitude": location.Latitude,
@@ -547,8 +522,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
}) })
} }
// AutoAssignAllPendingCommands assigne toutes les commandes en attente
// POST /api/v2/admin/protected/commands/auto-assign-all
func AutoAssignAllPendingCommands(c *gin.Context) { func AutoAssignAllPendingCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService) geoService := c.MustGet("geoService").(*services.GeoService)
@@ -559,7 +532,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
return return
} }
// Récupérer toutes les commandes pending
commands, err := database.GetAllCommands("pending", "") commands, err := database.GetAllCommands("pending", "")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
@@ -585,7 +557,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
for _, cmd := range commands { for _, cmd := range commands {
commandID, ok := cmd["id"].(int) commandID, ok := cmd["id"].(int)
if !ok { if !ok {
// Essayer avec float64
if idFloat, ok := cmd["id"].(float64); ok { if idFloat, ok := cmd["id"].(float64); ok {
commandID = int(idFloat) commandID = int(idFloat)
} else { } else {
@@ -593,7 +564,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
} }
} }
// Récupérer l'adresse
address, ok := cmd["adresse"].(string) address, ok := cmd["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" { if !ok || address == "" || address == "Adresse non spécifiée" {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
@@ -603,7 +573,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue continue
} }
// Géocoder l'adresse
location, err := geoService.GeocodeAddress(address) location, err := geoService.GeocodeAddress(address)
if err != nil { if err != nil {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
@@ -618,7 +587,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
Longitude: location.Longitude, Longitude: location.Longitude,
} }
// Récupérer les livreurs actifs
activeLivreurs, err := database.GetAllActiveDeliveryPersons() activeLivreurs, err := database.GetAllActiveDeliveryPersons()
if err != nil || len(activeLivreurs) == 0 { if err != nil || len(activeLivreurs) == 0 {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
@@ -633,7 +601,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
usernames[i] = livreur.Username usernames[i] = livreur.Username
} }
// Trouver le livreur le plus proche (version rapide pour assignation masse)
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames) nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
if err != nil { if err != nil {
failed = append(failed, gin.H{ failed = append(failed, gin.H{
@@ -643,7 +610,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue continue
} }
// Pour l'assignation en masse, on utilise le calcul rapide
travelTime := nearest.EstimatedTime travelTime := nearest.EstimatedTime
distance := nearest.Distance distance := nearest.Distance
@@ -657,13 +623,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
continue continue
} }
// Mettre à jour le statut du livreur
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
etaData, _ := database.GetCommandETA(commandID) etaData, _ := database.GetCommandETA(commandID)
var totalETA, waitTime interface{} var totalETA, waitTime any
totalETA = "N/A" totalETA = "N/A"
waitTime = "N/A" waitTime = "N/A"
@@ -688,7 +651,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA) log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA)
} }
// Récupérer l'overview des queues
queuesOverview, _ := database.GetAllQueuesOverview() queuesOverview, _ := database.GetAllQueuesOverview()
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -705,7 +667,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
} }
// GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs // GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs
// GET /api/v2/admin/protected/delivery/queues
func GetAllDeliveryQueues(c *gin.Context) { func GetAllDeliveryQueues(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -723,7 +684,6 @@ func GetAllDeliveryQueues(c *gin.Context) {
return return
} }
// Récupérer les détails de chaque livreur
var deliverymenDetails []gin.H var deliverymenDetails []gin.H
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result() keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
@@ -734,7 +694,7 @@ func GetAllDeliveryQueues(c *gin.Context) {
// Récupérer le statut // Récupérer le statut
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result() statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
var status map[string]interface{} var status map[string]any
if statusData != "" { if statusData != "" {
json.Unmarshal([]byte(statusData), &status) json.Unmarshal([]byte(statusData), &status)
} }
-6
View File
@@ -34,7 +34,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username) log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
// Récupérer la position GPS du livreur
lat, lon, err := database.GetDeliveryPersonLocation(username) lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil { if err != nil {
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err) log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
@@ -46,7 +45,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
return return
} }
// Validation des coordonnées
if lat == 0 && lon == 0 { if lat == 0 && lon == 0 {
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username) log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
@@ -57,7 +55,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
return return
} }
// Générer les liens de cartes
mapLinks := database.GenerateMapLinks(lat, lon, username) mapLinks := database.GenerateMapLinks(lat, lon, username)
log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon) log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon)
@@ -76,8 +73,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
}) })
} }
// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté
// GET /api/v1/livreur/deliveries/:id/nav-link
func GetLivreurNavLink(c *gin.Context) { func GetLivreurNavLink(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
username := c.GetString("username") username := c.GetString("username")
@@ -100,7 +95,6 @@ func GetLivreurNavLink(c *gin.Context) {
return return
} }
// Priorité : coordonnées GPS de la destination
var wazeLink string var wazeLink string
destLat, hasLat := command["dest_latitude"].(float64) destLat, hasLat := command["dest_latitude"].(float64)
destLon, hasLon := command["dest_longitude"].(float64) destLon, hasLon := command["dest_longitude"].(float64)
+4 -90
View File
@@ -4,92 +4,18 @@ import (
"fmt" "fmt"
"gestion/db" "gestion/db"
"log" "log"
"maps"
"net/http" "net/http"
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func GetMyCompletedOrders(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [HISTORY] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Authentification requise",
})
return
}
usernameStr := username.(string)
log.Printf("📚 [HISTORY] Récupération historique pour: %s", usernameStr)
// ✅ Récupérer les commandes terminées (approved)
commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil {
log.Printf("❌ [HISTORY] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération de l'historique",
})
return
}
log.Printf("✅ [HISTORY] %d commandes terminées trouvées", len(commands))
// ✅ Récupérer les infos client pour statistiques
client, err := database.GetClientByUsername(usernameStr)
// ✅ Récupérer les noms et clés des pools de points
poolNames := []string{"Pool 1", "Pool 2"}
var poolKeys []string
if settings, sErr := database.GetSettings(); sErr == nil && len(settings.PointsPools) > 0 {
poolNames = make([]string, len(settings.PointsPools))
poolKeys = make([]string, len(settings.PointsPools))
for i, p := range settings.PointsPools {
poolNames[i] = p.Name
poolKeys[i] = p.Key
}
}
response := gin.H{
"success": true,
"commands": commands,
"count": len(commands),
}
if err == nil && client != nil {
// Construire le tableau depuis points_extra[poolKey] pour tous les pools (stockage dynamique)
poolPoints := make([]int, len(poolKeys))
for i, key := range poolKeys {
if key != "" {
poolPoints[i] = client.PointsExtra[key]
}
}
log.Printf("📊 [HISTORY] pool_names=%v pool_keys=%v pool_points=%v extra=%v",
poolNames, poolKeys, poolPoints, client.PointsExtra)
response["client_stats"] = gin.H{
"username": client.Username,
"total_commands": client.Command,
"points_extra": client.PointsExtra,
"pool_points": poolPoints,
"pool_names": poolNames,
"penalties": client.Amende,
}
}
c.JSON(http.StatusOK, response)
}
// GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items // GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items
// GET /api/v1/my-commands/history/detailed // GET /api/v1/my-commands/history/detailed
func GetMyCompletedOrdersWithItems(c *gin.Context) { func GetMyCompletedOrdersWithItems(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username") username, exists := c.Get("username")
if !exists { if !exists {
log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié") log.Printf("❌ [HISTORY_DETAILED] Utilisateur non authentifié")
@@ -102,7 +28,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
usernameStr := username.(string) usernameStr := username.(string)
log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr) log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr)
// ✅ Récupérer les commandes terminées
commands, err := database.GetCompletedCommandsByUsername(usernameStr) commands, err := database.GetCompletedCommandsByUsername(usernameStr)
if err != nil { if err != nil {
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err) log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
@@ -112,7 +37,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
return return
} }
// ✅ Enrichir chaque commande avec ses items
var enrichedCommands []map[string]any var enrichedCommands []map[string]any
for _, command := range commands { for _, command := range commands {
@@ -121,7 +45,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
continue continue
} }
// Récupérer les items de cette commande
items, err := database.GetCommandItems(commandID) items, err := database.GetCommandItems(commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err) log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err)
@@ -130,9 +53,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
// Ajouter les items à la commande // Ajouter les items à la commande
enrichedCommand := make(map[string]any) enrichedCommand := make(map[string]any)
for k, v := range command { maps.Copy(enrichedCommand, command)
enrichedCommand[k] = v
}
enrichedCommand["items"] = items enrichedCommand["items"] = items
enrichedCommand["items_count"] = len(items) enrichedCommand["items_count"] = len(items)
@@ -141,7 +62,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands)) log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
// ✅ Récupérer les infos client
client, err := database.GetClientByUsername(usernameStr) client, err := database.GetClientByUsername(usernameStr)
response := gin.H{ response := gin.H{
@@ -168,7 +88,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
func GetOrderHistory(c *gin.Context) { func GetOrderHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username") username, exists := c.Get("username")
if !exists { if !exists {
log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié") log.Printf("❌ [ORDER_HISTORY] Utilisateur non authentifié")
@@ -180,7 +99,6 @@ func GetOrderHistory(c *gin.Context) {
usernameStr := username.(string) usernameStr := username.(string)
// Récupérer l'ID de la commande
var commandID int var commandID int
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil { if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@@ -191,7 +109,6 @@ func GetOrderHistory(c *gin.Context) {
log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr) log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr)
// ✅ Vérifier que la commande existe
command, err := database.GetCommandByID(commandID) command, err := database.GetCommandByID(commandID)
if err != nil { if err != nil {
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée") log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
@@ -201,7 +118,6 @@ func GetOrderHistory(c *gin.Context) {
return return
} }
// ✅ Vérifier que la commande appartient au client
cmdUsername, ok := command["username"].(string) cmdUsername, ok := command["username"].(string)
if !ok || cmdUsername != usernameStr { if !ok || cmdUsername != usernameStr {
log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr) log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr)
@@ -211,18 +127,16 @@ func GetOrderHistory(c *gin.Context) {
return return
} }
// ✅ Récupérer les logs de la commande
logs, err := database.GetCommandLogs(commandID) logs, err := database.GetCommandLogs(commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err) log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err)
logs = []map[string]interface{}{} logs = []map[string]any{}
} }
// ✅ Récupérer les items
items, err := database.GetCommandItems(commandID) items, err := database.GetCommandItems(commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err) log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err)
items = []map[string]interface{}{} items = []map[string]any{}
} }
log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items)) log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items))
@@ -0,0 +1,80 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
type loginHistoryWeek struct {
Week int `json:"week"`
Entries []db.LoginHistoryEntry `json:"entries"`
}
// GetLivreurLoginHistory retourne l'historique de connexion d'un livreur pour un mois donné,
// regroupé par semaine ISO (détail complet, pas d'agrégation par compteur).
func GetLivreurLoginHistory(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
now := time.Now()
year := now.Year()
month := int(now.Month())
if y := c.Query("year"); y != "" {
parsed, err := strconv.Atoi(y)
if err != nil || parsed < 2000 || parsed > 2100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Année invalide"})
return
}
year = parsed
}
if m := c.Query("month"); m != "" {
parsed, err := strconv.Atoi(m)
if err != nil || parsed < 1 || parsed > 12 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Mois invalide"})
return
}
month = parsed
}
database := c.MustGet("database").(*db.Database)
entries, err := database.GetLivreurLoginHistoryByMonth(username, year, month)
if err != nil {
utils.ServerErr(c, "Erreur récupération historique de connexion", err)
return
}
weekOrder := make([]int, 0)
weekMap := make(map[int]*loginHistoryWeek)
for _, e := range entries {
_, isoWeek := e.CreatedAt.ISOWeek()
w, ok := weekMap[isoWeek]
if !ok {
w = &loginHistoryWeek{Week: isoWeek}
weekMap[isoWeek] = w
weekOrder = append(weekOrder, isoWeek)
}
w.Entries = append(w.Entries, e)
}
weeks := make([]*loginHistoryWeek, 0, len(weekOrder))
for _, wk := range weekOrder {
weeks = append(weeks, weekMap[wk])
}
c.JSON(http.StatusOK, gin.H{
"username": username,
"year": year,
"month": month,
"weeks": weeks,
"count": len(entries),
})
}
+2 -3
View File
@@ -20,7 +20,6 @@ func GetClientNotifications(c *gin.Context) {
notifKey := "notifications:" + username notifKey := "notifications:" + username
// Récupérer toutes les notifications (max 50)
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result() results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
if err != nil { if err != nil {
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err) log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
@@ -128,7 +127,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
markedCount := 0 markedCount := 0
for i, raw := range results { for i, raw := range results {
var n map[string]interface{} var n map[string]any
if err := json.Unmarshal([]byte(raw), &n); err != nil { if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue continue
} }
@@ -171,7 +170,7 @@ func MarkNotificationsRead(c *gin.Context) {
// Réécrire chaque notification avec read=true // Réécrire chaque notification avec read=true
markedCount := 0 markedCount := 0
for i, raw := range results { for i, raw := range results {
var n map[string]interface{} var n map[string]any
if err := json.Unmarshal([]byte(raw), &n); err != nil { if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue continue
} }
+19 -71
View File
@@ -22,9 +22,6 @@ type BasketsRequest struct {
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
} }
// ============================================
// ✅ SÉCURISÉ: AddProductsBasket
// ============================================
// POST /api/v1/panier/add // POST /api/v1/panier/add
func AddProductsBasket(c *gin.Context) { func AddProductsBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -90,7 +87,6 @@ func GetAllBaskets(c *gin.Context) {
return return
} }
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username") authUsername, hasAuth := c.Get("username")
if !hasAuth { if !hasAuth {
log.Printf("❌ [GET_PANIER] Username manquant dans JWT") log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
@@ -100,7 +96,6 @@ func GetAllBaskets(c *gin.Context) {
authUsernameStr := authUsername.(string) authUsernameStr := authUsername.(string)
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
if username != authUsernameStr { if username != authUsernameStr {
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!") log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
log.Printf(" Username du JWT: %s", authUsernameStr) log.Printf(" Username du JWT: %s", authUsernameStr)
@@ -111,10 +106,8 @@ func GetAllBaskets(c *gin.Context) {
return return
} }
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
username = authUsernameStr username = authUsernameStr
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
_, err := database.GetClientByUsername(username) _, err := database.GetClientByUsername(username)
if err != nil { if err != nil {
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username) log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
@@ -130,7 +123,7 @@ func GetAllBaskets(c *gin.Context) {
var totalAmount float64 var totalAmount float64
for _, item := range baskets { for _, item := range baskets {
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts) totalAmount += item.Price
} }
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount) log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
@@ -156,7 +149,6 @@ func DeleteProductFromBasket(c *gin.Context) {
return return
} }
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username") authUsername, hasAuth := c.Get("username")
if !hasAuth { if !hasAuth {
log.Printf("❌ [DEL_PANIER] Username manquant dans JWT") log.Printf("❌ [DEL_PANIER] Username manquant dans JWT")
@@ -168,7 +160,6 @@ func DeleteProductFromBasket(c *gin.Context) {
log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr) log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr)
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
itemUsername, err := database.GetBasketItemOwner(req.ID) itemUsername, err := database.GetBasketItemOwner(req.ID)
if err != nil { if err != nil {
@@ -187,7 +178,6 @@ func DeleteProductFromBasket(c *gin.Context) {
return return
} }
// Supprimer l'article
err = database.DeleteProductFromBasket(req.ID) err = database.DeleteProductFromBasket(req.ID)
if err != nil { if err != nil {
utils.ServerErr(c, "Erreur lors de la suppression", err) utils.ServerErr(c, "Erreur lors de la suppression", err)
@@ -205,7 +195,6 @@ func DeleteProductFromBasket(c *gin.Context) {
func ClearBasket(c *gin.Context) { func ClearBasket(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
authUsername, hasAuth := c.Get("username") authUsername, hasAuth := c.Get("username")
if !hasAuth { if !hasAuth {
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT") log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
@@ -262,8 +251,8 @@ func ValidateBasket(c *gin.Context) {
var req struct { var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"` DeliveryAddress string `json:"delivery_address" binding:"required"`
UseReferralBalance bool `json:"use_referral_balance"` UseReferralBalance bool `json:"use_referral_balance"`
PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto" PaymentMethod string `json:"payment_method"`
PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto) PayCurrency string `json:"pay_currency"`
} }
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" { if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
@@ -277,7 +266,6 @@ func ValidateBasket(c *gin.Context) {
} }
req.DeliveryAddress = cmd.DeliveryAddress req.DeliveryAddress = cmd.DeliveryAddress
// Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées)
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked { if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"}) c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"})
@@ -287,9 +275,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr) log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr)
// ============================================
// 1️⃣ Vérifier que le panier n'est pas vide
// ============================================
items, err := database.GetBasketItems(usernameStr) items, err := database.GetBasketItems(usernameStr)
if err != nil { if err != nil {
utils.ServerErr(c, "Impossible de récupérer le panier", err) utils.ServerErr(c, "Impossible de récupérer le panier", err)
@@ -304,9 +289,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items)) log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
// ============================================
// 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant
// ============================================
var cartTotal float64 var cartTotal float64
for _, item := range items { for _, item := range items {
if price, ok := item["price"].(float64); ok { if price, ok := item["price"].(float64); ok {
@@ -314,7 +296,6 @@ func ValidateBasket(c *gin.Context) {
} }
} }
// Détecter si le panier contient un article récompense (prix 0)
hasRewardItem := false hasRewardItem := false
for _, item := range items { for _, item := range items {
if price, ok := item["price"].(float64); ok && price == 0 { if price, ok := item["price"].(float64); ok && price == 0 {
@@ -322,17 +303,13 @@ func ValidateBasket(c *gin.Context) {
break break
} }
} }
// Si récompense présente mais aucun produit payant → refuser
if hasRewardItem && cartTotal <= 0 { if hasRewardItem && cartTotal <= 0 {
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr) log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
return return
} }
// Récupérer les paramètres globaux (zones + parrainage)
appSettings, _ := database.GetSettings() appSettings, _ := database.GetSettings()
// Récupérer le solde parrainage disponible (seulement si le système est activé)
var referralBalance float64 var referralBalance float64
if req.UseReferralBalance && appSettings.ReferralEnabled { if req.UseReferralBalance && appSettings.ReferralEnabled {
referralBalance, _ = database.GetClientReferralBalance(usernameStr) referralBalance, _ = database.GetClientReferralBalance(usernameStr)
@@ -366,8 +343,6 @@ func ValidateBasket(c *gin.Context) {
return return
} }
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
var referralUsed float64 var referralUsed float64
if req.UseReferralBalance && referralBalance > 0 { if req.UseReferralBalance && referralBalance > 0 {
effectivePayment := cartTotal - referralBalance effectivePayment := cartTotal - referralBalance
@@ -399,7 +374,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr) log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
} }
// Vérifier que tous les produits du panier ont encore un prix actif
unavailable, err := database.GetUnavailableBasketItems(usernameStr) unavailable, err := database.GetUnavailableBasketItems(usernameStr)
if err != nil { if err != nil {
utils.ServerErr(c, "Erreur vérification produits", err) utils.ServerErr(c, "Erreur vérification produits", err)
@@ -414,11 +388,11 @@ func ValidateBasket(c *gin.Context) {
return return
} }
// Vérification option crypto
isCrypto := req.PaymentMethod == "crypto" isCrypto := req.PaymentMethod == "crypto"
if isCrypto { if isCrypto {
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient) npRaw, npExists := c.Get("nowpayments")
if !npOk || np == nil { np, npOk := npRaw.(*services.NowPaymentsClient)
if !npExists || !npOk || np == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
return return
} }
@@ -434,6 +408,10 @@ func ValidateBasket(c *gin.Context) {
_ = database.CreditClientReferral(usernameStr, referralUsed) _ = database.CreditClientReferral(usernameStr, referralUsed)
} }
log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err) log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err)
if strings.Contains(err.Error(), "stock insuffisant") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Le stock ou le produit n'est plus disponible, repasse commande"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"})
return return
} }
@@ -447,7 +425,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID) log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments.
if isCrypto { if isCrypto {
np := c.MustGet("nowpayments").(*services.NowPaymentsClient) np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c)) ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
@@ -460,7 +437,6 @@ func ValidateBasket(c *gin.Context) {
} }
payResp, err := np.CreatePayment(payReq) payResp, err := np.CreatePayment(payReq)
if err != nil { if err != nil {
// Annuler la commande et restaurer le panier / parrainage
_ = database.CancelCryptoCommand(commandID) _ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 { if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed) _ = database.CreditClientReferral(usernameStr, referralUsed)
@@ -470,14 +446,21 @@ func ValidateBasket(c *gin.Context) {
return return
} }
// Passer la commande en 'pending_payment' (attente confirmation)
if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil { if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err) log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
} }
priceAmt, _ := payResp.PriceAmount.Float64() priceAmt, _ := payResp.PriceAmount.Float64()
payAmt, _ := payResp.PayAmount.Float64() payAmt, _ := payResp.PayAmount.Float64()
_, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt) if _, err := database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt); err != nil {
log.Printf("❌ [CHECKOUT] Erreur enregistrement paiement crypto (commande %d, nowpayment %s): %v", commandID, payResp.PaymentID.String(), err)
_ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne lors de l'enregistrement du paiement"})
return
}
log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency) log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency)
c.JSON(http.StatusCreated, gin.H{ c.JSON(http.StatusCreated, gin.H{
@@ -495,32 +478,8 @@ func ValidateBasket(c *gin.Context) {
return return
} }
// Notifier immédiatement tous les admins et agents cabine
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress) go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
// ============================================
// 3️⃣ Décrémenter le stock et vider le panier
// ============================================
err = database.ClearBasketOnCheckout(usernameStr)
if err != nil {
// Stock insuffisant au moment du checkout (concurrent) → annuler la commande
if strings.Contains(err.Error(), "stock insuffisant") {
_ = database.CancelCryptoCommand(commandID)
if referralUsed > 0 {
_ = database.CreditClientReferral(usernameStr, referralUsed)
}
log.Printf("❌ [CHECKOUT] Stock insuffisant au moment de la validation: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Tu as trop attendu pour passer commande! Le stock ou le produit n'est plus disponible, repasse commande"})
return
}
utils.ServerErr(c, "Impossible de valider le panier", err)
return
}
log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé")
// ============================================
// 4️⃣ Auto-assignation livreur (optionnel)
// ============================================
var assigned bool var assigned bool
var assignInfo gin.H var assignInfo gin.H
@@ -540,7 +499,6 @@ func ValidateBasket(c *gin.Context) {
if err == nil { if err == nil {
log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance) log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance)
// ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic
travelTime, distance, err := services.CalculateETAWithTomTom( travelTime, distance, err := services.CalculateETAWithTomTom(
nearest.Location, nearest.Location,
services.Coordinates{ services.Coordinates{
@@ -558,7 +516,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance) log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
// Assigner la commande au livreur
err = database.AssignCommandToDeliverymanQueueWithCoords( err = database.AssignCommandToDeliverymanQueueWithCoords(
commandID, commandID,
nearest.Username, nearest.Username,
@@ -571,13 +528,10 @@ func ValidateBasket(c *gin.Context) {
if err != nil { if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err) log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
} else { } else {
// Mettre à jour le statut du livreur
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
if err != nil { if err != nil {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err) log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
} }
// Notifier le livreur de la nouvelle commande
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance) notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
if referralUsed > 0 { if referralUsed > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed) notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
@@ -585,8 +539,6 @@ func ValidateBasket(c *gin.Context) {
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil { if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr) log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
} }
// Notifier le client
clientOrderID := database.GetClientOrderID(commandID) clientOrderID := database.GetClientOrderID(commandID)
clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID) clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID)
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg) database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
@@ -609,9 +561,6 @@ func ValidateBasket(c *gin.Context) {
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err) log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
} }
// ============================================
// 5️⃣ Réponse
// ============================================
newBalance, _ := database.GetClientReferralBalance(usernameStr) newBalance, _ := database.GetClientReferralBalance(usernameStr)
resp := gin.H{ resp := gin.H{
"success": true, "success": true,
@@ -638,7 +587,6 @@ func ValidateBasket(c *gin.Context) {
c.JSON(http.StatusCreated, resp) c.JSON(http.StatusCreated, resp)
} }
// getBaseURL construit l'URL de base depuis la requête en cours
func getBaseURL(c *gin.Context) string { func getBaseURL(c *gin.Context) string {
scheme := "https" scheme := "https"
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
+9 -15
View File
@@ -9,8 +9,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin)
// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde.
func SetClientParrainAdmin(c *gin.Context) { func SetClientParrainAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username") targetUsername := c.Param("username")
@@ -28,14 +26,12 @@ func SetClientParrainAdmin(c *gin.Context) {
return return
} }
// Vérifier que le parrain existe
parrain, err := database.GetClientByUsername(req.Parrain) parrain, err := database.GetClientByUsername(req.Parrain)
if err != nil || parrain == nil { if err != nil || parrain == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"}) c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
return return
} }
// Vérifier que le client n'a pas déjà un parrain
existing, err := database.GetClientParrain(targetUsername) existing, err := database.GetClientParrain(targetUsername)
if err != nil { if err != nil {
utils.ServerErr(c, "Erreur vérification parrain", err) utils.ServerErr(c, "Erreur vérification parrain", err)
@@ -46,25 +42,23 @@ func SetClientParrainAdmin(c *gin.Context) {
return return
} }
if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil { settings, _ := database.GetSettings()
creditAmount := 0.0
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
creditAmount = settings.ReferralAmount
}
if err := database.SetClientParrainAndCredit(targetUsername, req.Parrain, creditAmount); err != nil {
utils.ServerErr(c, "Erreur enregistrement parrain", err) utils.ServerErr(c, "Erreur enregistrement parrain", err)
return return
} }
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
settings, _ := database.GetSettings()
if settings.ReferralEnabled && settings.ReferralAmount > 0 {
if err := database.CreditClientReferral(req.Parrain, settings.ReferralAmount); err != nil {
log.Printf("⚠️ [PARRAIN] Impossible de créditer %s: %v", req.Parrain, err)
} else {
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, settings.ReferralAmount)
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"message": "Parrain enregistré", "message": "Parrain enregistré",
"client": targetUsername, "client": targetUsername,
"parrain": req.Parrain, "parrain": req.Parrain,
"amount_credited": settings.ReferralAmount, "amount_credited": creditAmount,
}) })
} }
+133 -56
View File
@@ -11,6 +11,34 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// eligibleRewardProductIDs détermine, pour un pool donné, quels product_id de
// reward.RewardItems sont éligibles : 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 eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]bool {
eligible := make(map[int]bool)
if reward == nil {
return eligible
}
for _, cfg := range reward.CategoryConfigs {
if !poolCategories[cfg.Category] {
continue
}
if cfg.AllProducts {
for pid, cat := range productCategories {
if cat == cfg.Category {
eligible[pid] = true
}
}
} else {
for _, pid := range cfg.ProductIDs {
eligible[pid] = true
}
}
}
return eligible
}
// GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté. // GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté.
// La récompense est globale : son seuil s'applique indépendamment à chaque pool. // La récompense est globale : son seuil s'applique indépendamment à chaque pool.
func GetMyPointsRewards(c *gin.Context) { func GetMyPointsRewards(c *gin.Context) {
@@ -48,14 +76,22 @@ func GetMyPointsRewards(c *gin.Context) {
ProductNames []string `json:"product_names"` ProductNames []string `json:"product_names"`
} }
type RewardItemResponse struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
}
type PoolInfo struct { type PoolInfo struct {
Key string `json:"key"` Key string `json:"key"`
Name string `json:"name"` Name string `json:"name"`
Points int `json:"points"` Points int `json:"points"`
RewardsEarned int `json:"rewards_earned"` RewardsEarned int `json:"rewards_earned"`
RewardsClaimed int `json:"rewards_claimed"` RewardsClaimed int `json:"rewards_claimed"`
RewardsAvailable int `json:"rewards_available"` RewardsAvailable int `json:"rewards_available"`
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"` EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
} }
// Collecter tous les product_ids nécessaires en un seul passage // Collecter tous les product_ids nécessaires en un seul passage
@@ -73,6 +109,7 @@ func GetMyPointsRewards(c *gin.Context) {
} }
} }
productNames, _ := database.GetProductNamesByIDs(allProductIDs) productNames, _ := database.GetProductNamesByIDs(allProductIDs)
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
pools := make([]PoolInfo, 0, len(settings.PointsPools)) pools := make([]PoolInfo, 0, len(settings.PointsPools))
for _, pool := range settings.PointsPools { for _, pool := range settings.PointsPools {
@@ -83,12 +120,9 @@ func GetMyPointsRewards(c *gin.Context) {
if reward != nil && reward.Threshold > 0 { if reward != nil && reward.Threshold > 0 {
earned = pts / reward.Threshold earned = pts / reward.Threshold
available = earned - redeemed available = earned - redeemed
if available < 0 { available = max(earned-redeemed, 0)
available = 0
}
} }
// Filtrer les category_configs aux seules catégories du pool
poolCats := make(map[string]bool, len(pool.Categories)) poolCats := make(map[string]bool, len(pool.Categories))
for _, c := range pool.Categories { for _, c := range pool.Categories {
poolCats[c] = true poolCats[c] = true
@@ -114,24 +148,35 @@ func GetMyPointsRewards(c *gin.Context) {
} }
} }
eligibleProductIDs := eligibleRewardProductIDs(reward, poolCats, productCategories)
eligibleRewardItems := make([]RewardItemResponse, 0)
if reward != nil {
for _, item := range reward.RewardItems {
if !eligibleProductIDs[item.ProductID] {
continue
}
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: productNames[item.ProductID],
Quantity: item.Quantity,
Price: item.Price,
})
}
}
pools = append(pools, PoolInfo{ pools = append(pools, PoolInfo{
Key: pool.Key, Key: pool.Key,
Name: pool.Name, Name: pool.Name,
Points: pts, Points: pts,
RewardsEarned: earned, RewardsEarned: earned,
RewardsClaimed: redeemed, RewardsClaimed: redeemed,
RewardsAvailable: available, RewardsAvailable: available,
EligibleConfigs: eligibleConfigs, EligibleConfigs: eligibleConfigs,
EligibleRewardItems: eligibleRewardItems,
}) })
} }
// Construire la liste des produits récompense avec leurs noms // Construire la liste des produits récompense avec leurs noms
type RewardItemResponse struct {
ProductID int `json:"product_id"`
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
}
var rewardMeta gin.H var rewardMeta gin.H
if reward != nil { if reward != nil {
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems)) rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
@@ -168,7 +213,7 @@ func ClaimMyReward(c *gin.Context) {
var req struct { var req struct {
PoolKey string `json:"pool_key" binding:"required"` PoolKey string `json:"pool_key" binding:"required"`
ProductID int `json:"product_id"` // optionnel : 0 = automatique (1 seul item) ProductID int `json:"product_id"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"}) c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
@@ -194,53 +239,86 @@ func ClaimMyReward(c *gin.Context) {
return return
} }
// Vérifier que le pool existe // Vérifier que le pool existe et récupérer ses catégories
poolExists := false var selectedPool *models.PointsPool
for _, p := range settings.PointsPools { for i := range settings.PointsPools {
if p.Key == req.PoolKey { if settings.PointsPools[i].Key == req.PoolKey {
poolExists = true selectedPool = &settings.PointsPools[i]
break break
} }
} }
if !poolExists { if selectedPool == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
return return
} }
remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold) // 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.
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)
if err != nil {
utils.ServerErr(c, "Erreur lecture catégories produits", err)
return
}
eligibleProductIDs := eligibleRewardProductIDs(reward, poolCategories, productCategories)
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
if eligibleProductIDs[item.ProductID] {
eligibleItems = append(eligibleItems, item)
}
}
itemsToAdd := eligibleItems
if req.ProductID > 0 {
itemsToAdd = nil
for _, item := range eligibleItems {
if item.ProductID == req.ProductID {
itemsToAdd = []models.RewardItem{item}
break
}
}
if itemsToAdd == nil {
c.JSON(http.StatusForbidden, gin.H{"error": "Ce produit n'est pas éligible pour cette récompense"})
return
}
}
remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "pas de récompense disponible") { if strings.Contains(err.Error(), "pas de récompense disponible") {
c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"}) c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"})
return return
} }
if strings.Contains(err.Error(), "produit récompense introuvable") {
log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err)
c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
return
}
utils.ServerErr(c, "Erreur réclamation récompense", err) utils.ServerErr(c, "Erreur réclamation récompense", err)
return return
} }
// Si le client a sélectionné un produit spécifique parmi plusieurs, ne donner que celui-là productAdded := len(added) > 0
itemsToAdd := reward.RewardItems
if req.ProductID > 0 && len(reward.RewardItems) > 1 {
for _, item := range reward.RewardItems {
if item.ProductID == req.ProductID {
itemsToAdd = []models.RewardItem{item}
break
}
}
}
// Ajouter les produits récompense au panier si configurés
productAdded := false
var productNames []string var productNames []string
if len(itemsToAdd) > 0 { for _, item := range added {
if added, addErr := database.AddRewardsToBasket(username, itemsToAdd, req.PoolKey); addErr == nil && len(added) > 0 { productNames = append(productNames, item.ProductName)
productAdded = true }
for _, item := range added { if productAdded {
productNames = append(productNames, item.ProductName) log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
}
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
} else if addErr != nil {
log.Printf("⚠️ [CLAIM] Impossible d'ajouter produits récompense: %v", addErr)
}
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -252,7 +330,6 @@ func ClaimMyReward(c *gin.Context) {
}) })
} }
// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin).
func AdminResetClientRedeemed(c *gin.Context) { func AdminResetClientRedeemed(c *gin.Context) {
username := c.Param("username") username := c.Param("username")
poolKey := c.Query("pool_key") poolKey := c.Query("pool_key")
+80 -151
View File
@@ -4,12 +4,12 @@ import (
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/models" "gestion/models"
"gestion/services"
"gestion/utils" "gestion/utils"
"io"
"log" "log"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"os"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -116,7 +116,6 @@ func validateCategory(database *db.Database, category string) error {
return nil return nil
} }
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
file, err := fileHeader.Open() file, err := fileHeader.Open()
if err != nil { if err != nil {
@@ -130,7 +129,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
} }
mimeType := mtype.String() mimeType := mtype.String()
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
if idx := strings.Index(mimeType, ";"); idx != -1 { if idx := strings.Index(mimeType, ";"); idx != -1 {
mimeType = strings.TrimSpace(mimeType[:idx]) mimeType = strings.TrimSpace(mimeType[:idx])
} }
@@ -142,28 +140,9 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
return mimeType, nil return mimeType, nil
} }
// ✅ PROTECTION CONTRE PATH TRAVERSAL
func sanitizeFilePath(path string) (string, error) {
// Nettoyer le chemin
cleaned := filepath.Clean(path)
// Vérifier qu'il ne contient pas de ".."
if strings.Contains(cleaned, "..") {
return "", fmt.Errorf("path traversal détecté")
}
// Vérifier qu'il commence par "uploads/"
if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") {
return "", fmt.Errorf("chemin invalide")
}
return cleaned, nil
}
func CreateProduct(c *gin.Context) { func CreateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -172,14 +151,12 @@ func CreateProduct(c *gin.Context) {
username, _ := safeGetUsername(c) username, _ := safeGetUsername(c)
// ✅ PARSER AVEC LIMITE DE TAILLE
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil { if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err) log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
return return
} }
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
name := strings.TrimSpace(c.PostForm("name")) name := strings.TrimSpace(c.PostForm("name"))
category := strings.TrimSpace(c.PostForm("category")) category := strings.TrimSpace(c.PostForm("category"))
description := strings.TrimSpace(c.PostForm("description")) description := strings.TrimSpace(c.PostForm("description"))
@@ -189,7 +166,6 @@ func CreateProduct(c *gin.Context) {
unit = "u" unit = "u"
} }
// ✅ VALIDATION STRICTE
if err := validateProductName(name); err != nil { if err := validateProductName(name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -200,7 +176,6 @@ func CreateProduct(c *gin.Context) {
return return
} }
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
category = strings.ToLower(strings.TrimSpace(category)) category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune { category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 { if r < 32 || r == 127 {
@@ -219,7 +194,6 @@ func CreateProduct(c *gin.Context) {
return return
} }
// ✅ VALIDER LE STOCK
stock, err := strconv.ParseFloat(stockStr, 64) stock, err := strconv.ParseFloat(stockStr, 64)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
@@ -231,11 +205,10 @@ func CreateProduct(c *gin.Context) {
return return
} }
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
prices := []models.ProductPrice{} prices := []models.ProductPrice{}
priceIndex := 0 priceIndex := 0
for priceIndex < 100 { // Limite anti-spam for priceIndex < 100 {
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex) quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex) priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex) activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
@@ -323,7 +296,6 @@ func CreateProduct(c *gin.Context) {
return return
} }
// ✅ LIMITER LE NOMBRE DE FICHIERS
if len(files) > MaxFilesPerProduct { if len(files) > MaxFilesPerProduct {
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@@ -336,13 +308,13 @@ func CreateProduct(c *gin.Context) {
cleanProductName := cleanFileName(product.Name) cleanProductName := cleanFileName(product.Name)
uploadedMedia := []models.Media{} uploadedMedia := []models.Media{}
savedFiles := []string{} savedFiles := []models.Media{}
storage := c.MustGet("storage").(services.Storage)
var totalSize int64 = 0 var totalSize int64 = 0
for i, fileHeader := range files { for i, fileHeader := range files {
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
if fileHeader.Size > MaxFileSize { if fileHeader.Size > MaxFileSize {
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)), "error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)),
@@ -351,10 +323,8 @@ func CreateProduct(c *gin.Context) {
} }
totalSize += fileHeader.Size totalSize += fileHeader.Size
// ✅ VÉRIFIER LA TAILLE TOTALE
if totalSize > MaxTotalUploadSize { if totalSize > MaxTotalUploadSize {
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)), "error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)),
@@ -364,76 +334,50 @@ func CreateProduct(c *gin.Context) {
log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename) log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename)
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
mimeType, err := validateFileMimeType(fileHeader) mimeType, err := validateFileMimeType(fileHeader)
if err != nil { if err != nil {
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err) log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
return return
} }
// ✅ DÉTERMINER LE TYPE DE MÉDIA
var mediaType string var mediaType string
if strings.HasPrefix(mimeType, "image/") { if strings.HasPrefix(mimeType, "image/") {
mediaType = "image" mediaType = "image"
} else if strings.HasPrefix(mimeType, "video/") { } else if strings.HasPrefix(mimeType, "video/") {
mediaType = "video" mediaType = "video"
} else { } else {
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
return return
} }
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename) uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName)
destFolder := filepath.Join("uploads", mediaType+"s")
if err := os.MkdirAll(destFolder, 0750); err != nil {
log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"})
return
}
filePath := filepath.Join(destFolder, uniqueFileName)
// ✅ VALIDER LE CHEMIN (protection path traversal)
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil { if err != nil {
log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err)
rollbackFiles(savedFiles)
database.DeleteProduct(product.ID)
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
return
}
// ✅ SAUVEGARDER LE FICHIER
if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil {
log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err) log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err)
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return return
} }
savedFiles = append(savedFiles, safeFilePath) savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey})
// ✅ CRÉER L'ENTRÉE MÉDIA
mediaURL := "/" + filepath.ToSlash(safeFilePath)
media := models.Media{ media := models.Media{
ProductID: product.ID, ProductID: product.ID,
Type: mediaType, Type: mediaType,
URL: mediaURL, URL: mediaURL,
Key: mediaKey,
} }
if err := database.CreateMedia(&media); err != nil { if err := database.CreateMedia(&media); err != nil {
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err) log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
rollbackFiles(savedFiles) rollbackFiles(storage, savedFiles)
database.DeleteProduct(product.ID) database.DeleteProduct(product.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return return
@@ -480,7 +424,6 @@ func GetProductsByCategory(c *gin.Context) {
category := strings.ToLower(strings.TrimSpace(c.Param("category"))) category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(database, category); err != nil { if err := validateCategory(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "success": false,
@@ -499,7 +442,6 @@ func GetProductsByCategory(c *gin.Context) {
return return
} }
// ✅ Charger les médias
for i := range products { for i := range products {
media, _ := database.GetMediaByProductID(products[i].ID) media, _ := database.GetMediaByProductID(products[i].ID)
products[i].Media = media products[i].Media = media
@@ -533,11 +475,9 @@ func GetProductByID(c *gin.Context) {
}) })
return return
} }
// ✅ Charger les médias
media, _ := database.GetMediaByProductID(product.ID) media, _ := database.GetMediaByProductID(product.ID)
product.Media = media product.Media = media
// ✅ Filtrer les prix désactivés (sauf pour admin/cabine)
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
filterActivepricesSingle(&product) filterActivepricesSingle(&product)
@@ -552,7 +492,6 @@ func GetProductByID(c *gin.Context) {
func UpdateProduct(c *gin.Context) { func UpdateProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -567,7 +506,6 @@ func UpdateProduct(c *gin.Context) {
return return
} }
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
_, err = database.GetProductByID(id) _, err = database.GetProductByID(id)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
@@ -589,7 +527,6 @@ func UpdateProduct(c *gin.Context) {
return return
} }
// ✅ VALIDATION COMPLÈTE
if err := validateProductName(updateData.Name); err != nil { if err := validateProductName(updateData.Name); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return return
@@ -733,8 +670,8 @@ func UpdateStock(c *gin.Context) {
func DeleteMedia(c *gin.Context) { func DeleteMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
s3Service := c.MustGet("s3Service").(*services.S3Service)
// ✅ VÉRIFIER LE RÔLE
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" && role != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
@@ -753,28 +690,27 @@ func DeleteMedia(c *gin.Context) {
return return
} }
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION if err := database.DeleteMedia(mediaID); err != nil {
filePath := strings.TrimPrefix(media.URL, "/") log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
safeFilePath, err := sanitizeFilePath(filePath)
if err != nil {
log.Printf("❌ [DeleteMedia] Path invalide: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"})
return
}
// ✅ SUPPRIMER LE FICHIER PHYSIQUE
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err)
}
// ✅ SUPPRIMER DE LA DB
err = database.DeleteMedia(mediaID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
return return
} }
if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
} else {
log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key)
}
} else {
localStorage := services.NewLocalStorage("uploads")
if err := localStorage.Delete(media.URL, ""); err != nil {
log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err)
} else {
log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL)
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"message": "Média supprimé", "message": "Média supprimé",
@@ -784,7 +720,6 @@ func DeleteMedia(c *gin.Context) {
func UploadMedia(c *gin.Context) { func UploadMedia(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
// ✅ VÉRIFIER LE RÔLE
username, err := safeGetUsername(c) username, err := safeGetUsername(c)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
@@ -792,19 +727,17 @@ func UploadMedia(c *gin.Context) {
} }
role := c.GetString("role") role := c.GetString("role")
if role != "admin" && role != "cabine" { if role != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return return
} }
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
productID, err := strconv.Atoi(c.Param("id")) productID, err := strconv.Atoi(c.Param("id"))
if err != nil || productID <= 0 { if err != nil || productID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
return return
} }
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
productName, err := database.GetProductNameByID(productID) productName, err := database.GetProductNameByID(productID)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
@@ -813,7 +746,6 @@ func UploadMedia(c *gin.Context) {
log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName) log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName)
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
fileType := c.PostForm("type") fileType := c.PostForm("type")
if fileType != "image" && fileType != "video" { if fileType != "image" && fileType != "video" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
@@ -827,7 +759,6 @@ func UploadMedia(c *gin.Context) {
return return
} }
// ✅ VÉRIFIER LA TAILLE
const MaxFileSize = 10 * 1024 * 1024 // 10MB const MaxFileSize = 10 * 1024 * 1024 // 10MB
if file.Size > MaxFileSize { if file.Size > MaxFileSize {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
@@ -836,7 +767,6 @@ func UploadMedia(c *gin.Context) {
return return
} }
// ✅ VÉRIFIER LE TYPE MIME RÉEL
detectedMime, err := validateFileMimeType(file) detectedMime, err := validateFileMimeType(file)
if err != nil { if err != nil {
log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err) log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err)
@@ -846,7 +776,6 @@ func UploadMedia(c *gin.Context) {
log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime) log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime)
// Vérifier que le MIME correspond au type déclaré
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") { if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"}) c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
return return
@@ -856,40 +785,32 @@ func UploadMedia(c *gin.Context) {
return return
} }
// ✅ GÉNÉRER UN NOM UNIQUE
cleanProductName := cleanFileName(productName) cleanProductName := cleanFileName(productName)
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename) uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
// ✅ CRÉER LE DOSSIER storage := c.MustGet("storage").(services.Storage)
destFolder := filepath.Join("uploads", fileType+"s") folder := fileType + "s"
if err := os.MkdirAll(destFolder, 0750); err != nil { mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err) if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"}) log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
return return
} }
// ✅ SAUVEGARDER LE FICHIER log.Printf("✅ [UploadMedia] Fichier uploadé: %s", mediaURL)
filePath := filepath.Join(destFolder, uniqueFileName)
if err := c.SaveUploadedFile(file, filePath); err != nil {
log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
return
}
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
// ✅ CRÉER L'ENTRÉE EN BASE
mediaURL := "/" + filepath.ToSlash(filePath)
media := models.Media{ media := models.Media{
ProductID: productID, ProductID: productID,
Type: fileType, Type: fileType,
URL: mediaURL, URL: mediaURL,
Key: mediaKey,
} }
err = database.CreateMedia(&media) err = database.CreateMedia(&media)
if err != nil { if err != nil {
// Rollback: supprimer le fichier if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil {
os.Remove(filePath) log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr)
}
log.Printf("❌ [UploadMedia] Erreur DB: %v", err) log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
return return
@@ -908,6 +829,28 @@ func UploadMedia(c *gin.Context) {
}) })
} }
func ServeMedia(c *gin.Context) {
s3Service := c.MustGet("s3Service").(*services.S3Service)
key := strings.TrimPrefix(c.Param("key"), "/")
if key == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"})
return
}
body, contentType, err := s3Service.GetFile(c.Request.Context(), key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"})
return
}
defer body.Close()
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Status(http.StatusOK)
io.Copy(c.Writer, body)
}
func ActivePrice(c *gin.Context) { func ActivePrice(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
role := c.GetString("role") role := c.GetString("role")
@@ -952,10 +895,6 @@ func DesActivePrice(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"}) c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"})
} }
// ============================================
// DELETE PRODUCT - VERSION SÉCURISÉE
// ============================================
func DeleteProduct(c *gin.Context) { func DeleteProduct(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -976,32 +915,28 @@ func DeleteProduct(c *gin.Context) {
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id) log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
mediaList, err := database.GetMediaByProductID(id) mediaList, err := database.GetMediaByProductID(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
return return
} }
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ s3Service := c.MustGet("s3Service").(*services.S3Service)
localStorage := services.NewLocalStorage("uploads")
for _, media := range mediaList { for _, media := range mediaList {
filePath := strings.TrimPrefix(media.URL, "/") if media.Key != "" {
if err := s3Service.DeleteFile(media.Key); err != nil {
safeFilePath, err := sanitizeFilePath(filePath) log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
if err != nil { }
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err) } else {
continue if err := localStorage.Delete(media.URL, ""); err != nil {
} log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err)
}
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err)
} }
} }
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
database.DeleteMediaByProductID(id) database.DeleteMediaByProductID(id)
// ✅ SUPPRIMER LE PRODUIT
err = database.DeleteProduct(id) err = database.DeleteProduct(id)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"}) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
@@ -1016,17 +951,11 @@ func DeleteProduct(c *gin.Context) {
}) })
} }
// ============================================ func rollbackFiles(storage services.Storage, files []models.Media) {
// HELPERS for _, f := range files {
// ============================================ if err := storage.Delete(f.URL, f.Key); err != nil {
log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err)
func rollbackFiles(files []string) {
for _, file := range files {
safeFilePath, err := sanitizeFilePath(file)
if err != nil {
continue
} }
os.Remove(safeFilePath)
} }
} }
+140
View File
@@ -0,0 +1,140 @@
package handlers
import (
"gestion/db"
"gestion/utils"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
func SubmitLivreurRating(c *gin.Context) {
clientUsername := c.GetString("username")
if clientUsername == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
orderID, err := strconv.Atoi(c.Param("id"))
if err != nil || orderID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
return
}
var req struct {
Rating int `json:"rating" binding:"required,min=1,max=5"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Note invalide (1 à 5 requis)"})
return
}
database := c.MustGet("database").(*db.Database)
ownerUsername, livreurUsername, err := database.GetOrderForRating(orderID)
if err != nil {
utils.ServerErr(c, "Erreur lecture commande", err)
return
}
if ownerUsername == "" {
c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable ou non terminée"})
return
}
if ownerUsername != clientUsername {
c.JSON(http.StatusForbidden, gin.H{"error": "Commande non autorisée"})
return
}
if livreurUsername == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun livreur assigné à cette commande"})
return
}
existing, err := database.GetOrderRating(orderID)
if err != nil {
utils.ServerErr(c, "Erreur vérification avis", err)
return
}
if existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "Vous avez déjà noté ce livreur pour cette commande"})
return
}
if err := database.SubmitLivreurRating(orderID, livreurUsername, clientUsername, req.Rating, req.Comment); err != nil {
utils.ServerErr(c, "Erreur enregistrement avis", err)
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
func GetLivreurRatings(c *gin.Context) {
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
database := c.MustGet("database").(*db.Database)
ratings, avg, err := database.GetLivreurRatings(username)
if err != nil {
utils.ServerErr(c, "Erreur récupération avis", err)
return
}
c.JSON(http.StatusOK, gin.H{
"ratings": ratings,
"average": avg,
"count": len(ratings),
})
}
// GetMyRatings retourne les avis reçus par le livreur connecté (uniquement les siens).
func GetMyRatings(c *gin.Context) {
username := c.GetString("username")
if username == "" || c.GetString("role") != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
database := c.MustGet("database").(*db.Database)
ratings, avg, err := database.GetLivreurRatings(username)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération avis"})
return
}
c.JSON(http.StatusOK, gin.H{
"ratings": ratings,
"average": avg,
"count": len(ratings),
})
}
func GetOrderRatingStatus(c *gin.Context) {
clientUsername := c.GetString("username")
if clientUsername == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
orderID, err := strconv.Atoi(c.Param("id"))
if err != nil || orderID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
database := c.MustGet("database").(*db.Database)
rating, err := database.GetOrderRating(orderID)
if err != nil {
utils.ServerErr(c, "Erreur", err)
return
}
if rating == nil {
c.JSON(http.StatusOK, gin.H{"rated": false})
return
}
c.JSON(http.StatusOK, gin.H{"rated": true, "rating": rating.Rating, "comment": rating.Comment})
}
+22 -44
View File
@@ -307,19 +307,22 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
} }
// ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible) // ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible)
// La clé est un hash (HSet), jamais une simple valeur — Redis.Get renvoie
// une erreur WRONGTYPE dessus, silencieusement ignorée ici auparavant,
// ce qui faisait toujours renvoyer etaMinutes=0.
etaKey := fmt.Sprintf("command:eta:%d", commandID) etaKey := fmt.Sprintf("command:eta:%d", commandID)
etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result() eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
var etaMinutes int = 0 var etaMinutes int = 0
var etaSetAt int64 = 0 var etaSetAt int64 = 0
if etaData != "" { if minutesStr, ok := eta["eta_minutes"]; ok {
var eta map[string]interface{} if minutes, err := strconv.Atoi(minutesStr); err == nil {
json.Unmarshal([]byte(etaData), &eta) etaMinutes = minutes
if minutes, ok := eta["minutes"].(float64); ok {
etaMinutes = int(minutes)
} }
if timestamp, ok := eta["set_at"].(float64); ok { }
etaSetAt = int64(timestamp) if updatedAtStr, ok := eta["updated_at"]; ok {
if timestamp, err := strconv.ParseInt(updatedAtStr, 10, 64); err == nil {
etaSetAt = timestamp
} }
} }
@@ -947,36 +950,6 @@ func SubtractClientPointsAdmin(c *gin.Context) {
}) })
} }
func GetRealtimeStats(c *gin.Context) {
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
stats, err := db.Redis.HGetAll(db.RedisCtx, "stats:realtime").Result()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération des statistiques",
})
return
}
if len(stats) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Aucune statistique disponible pour le moment",
"stats": map[string]interface{}{},
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"stats": stats,
})
}
func refreshETAForActivDelivery(username string, lat, lon float64) { func refreshETAForActivDelivery(username string, lat, lon float64) {
// 1. Récupérer le statut actuel du livreur // 1. Récupérer le statut actuel du livreur
statusKey := fmt.Sprintf("delivery:status:%s", username) statusKey := fmt.Sprintf("delivery:status:%s", username)
@@ -1047,12 +1020,17 @@ func refreshETAForActivDelivery(username string, lat, lon float64) {
etaKey := fmt.Sprintf("command:eta:%d", commandID) etaKey := fmt.Sprintf("command:eta:%d", commandID)
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{ db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
"command_id": commandID, "command_id": commandID,
"eta_minutes": etaMinutes, // eta_minutes ET total_eta_minutes doivent tous les deux être présents
"updated_at": now.Unix(), // (voir le commentaire de SetCommandETAWithDetails) — sans quoi les
"arrival_time": arrivalTime.Unix(), // lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien.
"distance_km": distanceKm, "eta_minutes": etaMinutes,
"with_traffic": err == nil, "total_eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"estimated_arrival": arrivalTime.Format(time.RFC3339),
"distance_km": distanceKm,
"with_traffic": err == nil,
}) })
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour) db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
} }
+7
View File
@@ -48,6 +48,13 @@ func GetPublicSettings(c *gin.Context) {
"shop_name": settings.ShopName, "shop_name": settings.ShopName,
"two_fa_enabled": settings.Telegram2FAEnabled, "two_fa_enabled": settings.Telegram2FAEnabled,
"contact_telegram": settings.ContactTelegram, "contact_telegram": settings.ContactTelegram,
"client_color_primary": settings.ClientColorPrimary,
"client_color_secondary": settings.ClientColorSecondary,
"client_color_success": settings.ClientColorSuccess,
"client_color_danger": settings.ClientColorDanger,
"client_color_warning": settings.ClientColorWarning,
"client_title_gradient_from": settings.ClientTitleGradientFrom,
"client_title_gradient_to": settings.ClientTitleGradientTo,
}) })
} }
+320 -121
View File
@@ -1,38 +1,270 @@
package handlers package handlers
import ( import (
"context"
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/models" "gestion/models"
"net/http" "net/http"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"golang.org/x/sync/errgroup"
) )
var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"} var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"}
// sections valides pour le reset
var validStatsSections = map[string]string{
"commandes": "stats_reset_commandes_at",
"revenus": "stats_reset_revenus_at",
"produits": "stats_reset_produits_at",
"heures": "stats_reset_heures_at",
"jours": "stats_reset_jours_at",
"doses": "stats_reset_doses_at",
}
// ResetAdminStats réinitialise une section précise des statistiques.
func ResetAdminStats(c *gin.Context) {
section := c.Param("section")
key, ok := validStatsSections[section]
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
return
}
database := c.MustGet("database").(*db.Database)
now := time.Now().UTC().Format(time.RFC3339)
if err := database.ResetAdminStat(key); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now})
}
func dateFilter(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre
// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de
// commandes, le revenu et la quantité vendue. Les jours sans commande sont
// inclus avec des valeurs à zéro.
func GetAdminStatsByMonth(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
monthParam := c.Query("month")
monthStart := time.Now()
if monthParam != "" {
parsed, err := time.Parse("2006-01", monthParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)})
return
}
monthStart = parsed
}
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
resetCmd := database.ReadResetAt("stats_reset_commandes_at")
var rows []db.DailyMonthStatRow
if err := database.StatsByDayForMonth(&rows, monthStart, resetCmd); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
return
}
rowByDay := make(map[string]db.DailyMonthStatRow, len(rows))
for _, r := range rows {
rowByDay[r.Day.Format("2006-01-02")] = r
}
daysInMonth := monthStart.AddDate(0, 1, -1).Day()
byDay := make([]gin.H, daysInMonth)
var totalOrders int
var totalRevenue float64
var totalQuantity float64
for i := range daysInMonth {
day := monthStart.AddDate(0, 0, i)
key := day.Format("2006-01-02")
r, ok := rowByDay[key]
if !ok {
r = db.DailyMonthStatRow{Day: day}
}
byDay[i] = gin.H{
"day": key,
"label": day.Format("02/01"),
"count": r.Count,
"revenue": r.Revenue,
"quantity": r.Quantity,
}
totalOrders += r.Count
totalRevenue += r.Revenue
totalQuantity += r.Quantity
}
c.JSON(http.StatusOK, gin.H{
"month": monthStart.Format("2006-01"),
"summary": gin.H{
"total_orders": totalOrders,
"total_revenue": totalRevenue,
"total_quantity": totalQuantity,
},
"by_day": byDay,
})
}
func GetAdminDailyDetail(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
dateParam := c.Query("date")
date := time.Now()
if dateParam != "" {
parsed, err := time.Parse("2006-01-02", dateParam)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre date invalide (attendu YYYY-MM-DD) : %s", dateParam)})
return
}
date = parsed
}
var dailyRows []models.DailyProductRow
if err := database.DailyProductDetailForDate(&dailyRows, date); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération du détail du jour: %s", err)})
return
}
type dailyCatGroup struct {
Category string
CategoryColor string
TotalQuantity float64
TotalRevenue float64
Products []gin.H
}
var dailyCats []dailyCatGroup
dailyCatIdx := map[string]int{}
dailyTotalRevenue := 0.0
dailyTotalQty := 0.0
for _, r := range dailyRows {
dailyTotalRevenue += r.Revenue
dailyTotalQty += r.TotalQuantity
idx, ok := dailyCatIdx[r.Category]
if !ok {
idx = len(dailyCats)
dailyCats = append(dailyCats, dailyCatGroup{
Category: r.Category,
CategoryColor: r.CategoryColor,
})
dailyCatIdx[r.Category] = idx
}
dailyCats[idx].TotalQuantity += r.TotalQuantity
dailyCats[idx].TotalRevenue += r.Revenue
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
"product_id": r.ProductID,
"name": r.ProductName,
"quantity": r.TotalQuantity,
"order_count": r.OrderCount,
"revenue": r.Revenue,
})
}
dailyCatsJSON := make([]gin.H, len(dailyCats))
for i, g := range dailyCats {
dailyCatsJSON[i] = gin.H{
"category": g.Category,
"category_color": g.CategoryColor,
"total_quantity": g.TotalQuantity,
"total_revenue": g.TotalRevenue,
"products": g.Products,
}
}
dailyTotalOrders, _ := database.DailyOrdersCountForDate(date)
c.JSON(http.StatusOK, gin.H{
"date": date.Format("02/01/2006"),
"total_orders": dailyTotalOrders,
"total_quantity": dailyTotalQty,
"total_revenue": dailyTotalRevenue,
"categories": dailyCatsJSON,
})
}
// GetAdminStats returns aggregated order & product statistics for the admin dashboard. // GetAdminStats returns aggregated order & product statistics for the admin dashboard.
func GetAdminStats(c *gin.Context) { func GetAdminStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
gdb := database.GDB
// ── Commandes par jour de la semaine (all time, non annulées) ────────────── filters := database.LoadAdminStatsFilters()
var wdRows []models.WeekdayRow
gdb.Raw(`
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
FROM commandes
WHERE status != 'cancelled'
GROUP BY dow
ORDER BY dow
`).Scan(&wdRows)
// 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
)
eg, _ := errgroup.WithContext(context.Background())
eg.Go(func() error { return database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) })
eg.Go(func() error { return database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) })
eg.Go(func() error { return database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) })
eg.Go(func() error { return database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) })
eg.Go(func() error { return database.TopProducts(&prodRows, filters.ResetProduits, 15) })
eg.Go(func() error { return database.QuantityBreakdown(&qtyRows, filters.ResetDoses) })
eg.Go(func() error { return database.DailyProductDetail(&dailyRows) })
eg.Go(func() error {
var err error
totalOrders, err = database.TotalOrders(filters.ResetCommandes)
return err
})
eg.Go(func() error {
var err error
totalRevenue, err = database.TotalRevenue(filters.ResetRevenus)
return err
})
eg.Go(func() error {
var err error
dailyTotalOrders, err = database.DailyOrdersCount()
return err
})
eg.Go(func() error {
var err error
activeDays, err = database.ActiveDaysLast30(filters.ResetCommandes)
return err
})
eg.Go(func() error {
var err error
last30Count, err = database.OrdersCountLast30(filters.ResetCommandes)
return err
})
if err := eg.Wait(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des statistiques"})
return
}
// ── Commandes par jour de la semaine ──────────────────────────────────────
byWeekday := make([]gin.H, 7) byWeekday := make([]gin.H, 7)
wdMap := make(map[int]int, len(wdRows)) wdMap := make(map[int]int, len(wdRows))
for _, r := range wdRows { for _, r := range wdRows {
wdMap[r.DOW] = r.Count wdMap[r.DOW] = r.Count
} }
peakCount, peakWeekday := 0, "" peakCount, peakWeekday := 0, ""
for i := 0; i < 7; i++ { for i := range 7 {
cnt := wdMap[i] cnt := wdMap[i]
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt} byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
if cnt > peakCount { if cnt > peakCount {
@@ -42,16 +274,6 @@ func GetAdminStats(c *gin.Context) {
} }
// ── Commandes par jour sur 30 jours ─────────────────────────────────────── // ── Commandes par jour sur 30 jours ───────────────────────────────────────
var dayRows []models.DayRow
gdb.Raw(`
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND status != 'cancelled'
GROUP BY DATE(created_at)
ORDER BY day
`).Scan(&dayRows)
byDay := make([]gin.H, len(dayRows)) byDay := make([]gin.H, len(dayRows))
for i, r := range dayRows { for i, r := range dayRows {
byDay[i] = gin.H{ byDay[i] = gin.H{
@@ -61,17 +283,7 @@ func GetAdminStats(c *gin.Context) {
} }
} }
// ── Revenus par jour sur 30 jours (commandes approuvées) ───────────────── // ── Revenus par jour sur 30 jours ─────────────────────────────────────────
var dayRevRows []models.DayRevenueRow
gdb.Raw(`
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days'
AND status = 'approved'
GROUP BY DATE(created_at)
ORDER BY day
`).Scan(&dayRevRows)
byDayRevenue := make([]gin.H, len(dayRevRows)) byDayRevenue := make([]gin.H, len(dayRevRows))
for i, r := range dayRevRows { for i, r := range dayRevRows {
byDayRevenue[i] = gin.H{ byDayRevenue[i] = gin.H{
@@ -81,25 +293,13 @@ func GetAdminStats(c *gin.Context) {
} }
} }
// ── Commandes & revenus par heure (all time, non annulées) ─────────────── // ── Commandes & revenus par heure ─────────────────────────────────────────
var hourRows []models.HourRow
gdb.Raw(`
SELECT
EXTRACT(HOUR FROM created_at)::int AS hour,
COUNT(*) AS count,
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE status != 'cancelled'
GROUP BY hour
ORDER BY hour
`).Scan(&hourRows)
hourMap := make(map[int]models.HourRow, len(hourRows)) hourMap := make(map[int]models.HourRow, len(hourRows))
for _, r := range hourRows { for _, r := range hourRows {
hourMap[r.Hour] = r hourMap[r.Hour] = r
} }
byHour := make([]gin.H, 24) byHour := make([]gin.H, 24)
for h := 0; h < 24; h++ { for h := range 24 {
r := hourMap[h] r := hourMap[h]
byHour[h] = gin.H{ byHour[h] = gin.H{
"hour": h, "hour": h,
@@ -109,27 +309,7 @@ func GetAdminStats(c *gin.Context) {
} }
} }
// ── Top produits (quantité vendue, commandes terminées) ─────────────────── // ── Top produits ──────────────────────────────────────────────────────────
var prodRows []models.ProductRow
gdb.Raw(`
SELECT
ci.product_id,
ci.produit AS name,
SUM(ci.quantite) AS total_quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(ci.prix) AS revenue,
COALESCE(p.category, '') AS category,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, p.category, cat.color
ORDER BY total_quantity DESC
LIMIT 15
`).Scan(&prodRows)
topProducts := make([]gin.H, len(prodRows)) topProducts := make([]gin.H, len(prodRows))
topProductName := "" topProductName := ""
for i, r := range prodRows { for i, r := range prodRows {
@@ -147,26 +327,7 @@ func GetAdminStats(c *gin.Context) {
} }
} }
// ── Répartition des doses/quantités par produit ─────────────────────────── // ── Répartition des doses/quantités ───────────────────────────────────────
var qtyRows []models.QuantityBreakdownRow
gdb.Raw(`
SELECT
ci.product_id,
ci.produit AS product_name,
ci.quantite AS quantity,
COUNT(DISTINCT ci.command_id) AS order_count,
SUM(ci.quantite) AS total_sold,
SUM(ci.prix) AS revenue,
COALESCE(cat.color, '#7c3aed') AS category_color
FROM command_items ci
JOIN commandes c ON c.id = ci.command_id
LEFT JOIN products p ON p.id = ci.product_id
LEFT JOIN categories cat ON cat.name = p.category
WHERE c.status != 'cancelled'
GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color
ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC
`).Scan(&qtyRows)
type productGroup struct { type productGroup struct {
ProductID int ProductID int
Name string Name string
@@ -195,7 +356,6 @@ func GetAdminStats(c *gin.Context) {
"revenue": r.Revenue, "revenue": r.Revenue,
}) })
} }
// Trier par total de commandes décroissant, garder 15 max
for i := 0; i < len(groups)-1; i++ { for i := 0; i < len(groups)-1; i++ {
for j := i + 1; j < len(groups); j++ { for j := i + 1; j < len(groups); j++ {
if groups[j].TotalOrders > groups[i].TotalOrders { if groups[j].TotalOrders > groups[i].TotalOrders {
@@ -207,39 +367,65 @@ func GetAdminStats(c *gin.Context) {
groups = groups[:15] groups = groups[:15]
} }
byQuantity := make([]gin.H, len(groups)) byQuantity := make([]gin.H, len(groups))
for i, g := range groups { for i, grp := range groups {
byQuantity[i] = gin.H{ byQuantity[i] = gin.H{
"product_id": g.ProductID, "product_id": grp.ProductID,
"name": g.Name, "name": grp.Name,
"category_color": g.CategoryColor, "category_color": grp.CategoryColor,
"total_orders": g.TotalOrders, "total_orders": grp.TotalOrders,
"quantities": g.Quantities, "quantities": grp.Quantities,
}
}
// ── Détail du jour ────────────────────────────────────────────────────────
type dailyCatGroup struct {
Category string
CategoryColor string
TotalQuantity float64
TotalRevenue float64
Products []gin.H
}
var dailyCats []dailyCatGroup
dailyCatIdx := map[string]int{}
dailyTotalRevenue := 0.0
dailyTotalQty := 0.0
for _, r := range dailyRows {
dailyTotalRevenue += r.Revenue
dailyTotalQty += r.TotalQuantity
idx, ok := dailyCatIdx[r.Category]
if !ok {
idx = len(dailyCats)
dailyCats = append(dailyCats, dailyCatGroup{
Category: r.Category,
CategoryColor: r.CategoryColor,
})
dailyCatIdx[r.Category] = idx
}
dailyCats[idx].TotalQuantity += r.TotalQuantity
dailyCats[idx].TotalRevenue += r.Revenue
dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{
"product_id": r.ProductID,
"name": r.ProductName,
"quantity": r.TotalQuantity,
"order_count": r.OrderCount,
"revenue": r.Revenue,
})
}
dailyCatsJSON := make([]gin.H, len(dailyCats))
for i, grp := range dailyCats {
dailyCatsJSON[i] = gin.H{
"category": grp.Category,
"category_color": grp.CategoryColor,
"total_quantity": grp.TotalQuantity,
"total_revenue": grp.TotalRevenue,
"products": grp.Products,
} }
} }
// ── Résumé global ───────────────────────────────────────────────────────── // ── Résumé global ─────────────────────────────────────────────────────────
var totalOrders int64
var totalRevenue float64
gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders)
gdb.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue)
avgPerDay := 0.0 avgPerDay := 0.0
if totalOrders > 0 { if totalOrders > 0 && activeDays > 0 {
// average over the last 30 days with data avgPerDay = float64(last30Count) / float64(activeDays)
var activeDays int64
gdb.Raw(`
SELECT COUNT(DISTINCT DATE(created_at))
FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
`).Scan(&activeDays)
if activeDays > 0 {
var last30Count int64
gdb.Raw(`
SELECT COUNT(*) FROM commandes
WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled'
`).Scan(&last30Count)
avgPerDay = float64(last30Count) / float64(activeDays)
}
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -250,11 +436,24 @@ func GetAdminStats(c *gin.Context) {
"top_product": topProductName, "top_product": topProductName,
"avg_per_day": avgPerDay, "avg_per_day": avgPerDay,
}, },
"by_weekday": byWeekday, "reset_at_commandes": dateFilter(filters.ResetCommandes),
"by_day_30": byDay, "reset_at_revenus": dateFilter(filters.ResetRevenus),
"by_day_revenue": byDayRevenue, "reset_at_produits": dateFilter(filters.ResetProduits),
"by_hour": byHour, "reset_at_heures": dateFilter(filters.ResetHeures),
"top_products": topProducts, "reset_at_jours": dateFilter(filters.ResetJours),
"by_quantity": byQuantity, "reset_at_doses": dateFilter(filters.ResetDoses),
"by_weekday": byWeekday,
"by_day_30": byDay,
"by_day_revenue": byDayRevenue,
"by_hour": byHour,
"top_products": topProducts,
"by_quantity": byQuantity,
"daily_detail": gin.H{
"date": time.Now().Format("02/01/2006"),
"total_orders": dailyTotalOrders,
"total_quantity": dailyTotalQty,
"total_revenue": dailyTotalRevenue,
"categories": dailyCatsJSON,
},
}) })
} }
@@ -198,9 +198,6 @@ func UpdateClientByAdmin(c *gin.Context) {
return return
} }
// ✅ LOG DEBUG - Voir ce qui est reçu
log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req)
// Récupérer le client actuel // Récupérer le client actuel
client, err := database.GetClientByID(clientID) client, err := database.GetClientByID(clientID)
if err != nil { if err != nil {
+26 -1
View File
@@ -92,6 +92,29 @@ func main() {
}() }()
} }
s3Service, err := services.NewS3Service(
os.Getenv("S3_REGION"),
os.Getenv("S3_BUCKET"),
os.Getenv("S3_ENDPOINT"),
services.S3Credentials{
S3KeyId: os.Getenv("RUSTFS_ACCESS_KEY"),
S3AccessKey: os.Getenv("RUSTFS_SECRET_KEY"),
},
)
if err != nil {
log.Fatalf("erreur init S3: %v", err)
}
var storage services.Storage
switch os.Getenv("STORAGE_DRIVER") {
case "s3":
storage = services.NewS3Storage(s3Service)
log.Println("✅ Storage driver: s3 (RustFS)")
default:
storage = services.NewLocalStorage("uploads")
log.Println("✅ Storage driver: local")
}
log.Println("") log.Println("")
log.Println("🧹 Démarrage du nettoyage des commandes invalides...") log.Println("🧹 Démarrage du nettoyage des commandes invalides...")
removed, err := database.CleanupInvalidQueueCommands() removed, err := database.CleanupInvalidQueueCommands()
@@ -153,6 +176,8 @@ func main() {
r.Use(func(c *gin.Context) { r.Use(func(c *gin.Context) {
c.Set("database", database) c.Set("database", database)
c.Set("geoService", geoService) c.Set("geoService", geoService)
c.Set("s3Service", s3Service)
c.Set("storage", storage)
c.Next() c.Next()
}) })
@@ -167,7 +192,7 @@ func main() {
r.Static("/uploads", "./uploads") r.Static("/uploads", "./uploads")
routes.SetupRoutes(r, database, geoService) routes.SetupRoutes(r, database, geoService, s3Service)
if err := r.Run(":8080"); err != nil { if err := r.Run(":8080"); err != nil {
log.Fatalf("❌ Erreur au lancement du serveur : %v", err) log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
+6 -5
View File
@@ -3,11 +3,12 @@ package models
import "time" import "time"
type Media struct { type Media struct {
ID int `gorm:"primaryKey;autoIncrement" json:"id"` ID int `json:"id"`
ProductID int `gorm:"column:product_id" json:"product_id"` ProductID int `json:"product_id"`
Type string `gorm:"column:type" json:"type"` Type string `json:"type"`
URL string `gorm:"column:url" json:"url"` URL string `json:"url"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` Key string `json:"-"` // clé interne RustFS, jamais exposée
CreatedAt time.Time `json:"created_at"`
} }
func (Media) TableName() string { return "media" } func (Media) TableName() string { return "media" }
+15
View File
@@ -106,4 +106,19 @@ type AppSettings struct {
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client 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 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 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"`
AdminColorSuccess string `json:"admin_color_success"`
AdminColorDanger string `json:"admin_color_danger"`
AdminColorWarning string `json:"admin_color_warning"`
// Palette de couleurs — app client + site web
ClientColorPrimary string `json:"client_color_primary"`
ClientColorSecondary string `json:"client_color_secondary"`
ClientColorSuccess string `json:"client_color_success"`
ClientColorDanger string `json:"client_color_danger"`
ClientColorWarning string `json:"client_color_warning"`
// Dégradé du titre boutique sur le site web client
ClientTitleGradientFrom string `json:"client_title_gradient_from"`
ClientTitleGradientTo string `json:"client_title_gradient_to"`
} }
+32
View File
@@ -42,3 +42,35 @@ type DayRevenueRow struct {
Day time.Time `gorm:"column:day"` Day time.Time `gorm:"column:day"`
Revenue float64 `gorm:"column:revenue"` Revenue float64 `gorm:"column:revenue"`
} }
type DailyProductRow struct {
ProductID int `gorm:"column:product_id"`
ProductName string `gorm:"column:product_name"`
Category string `gorm:"column:category"`
CategoryColor string `gorm:"column:category_color"`
TotalQuantity float64 `gorm:"column:total_quantity"`
OrderCount int `gorm:"column:order_count"`
Revenue float64 `gorm:"column:revenue"`
}
type DayRowWithResult struct {
Day time.Time `gorm:"column:day"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type WeekRow struct {
WeekNum int `gorm:"column:week_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type MonthRow struct {
MonthNum int `gorm:"column:month_num"`
Year int `gorm:"column:year"`
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
type TodayRow struct {
Count int `gorm:"column:count"`
Revenue float64 `gorm:"column:revenue"`
}
+25 -4
View File
@@ -9,7 +9,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService) { func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService, s3Service *services.S3Service) {
// ============================================ // ============================================
// 🔐 MIDDLEWARE GLOBAL // 🔐 MIDDLEWARE GLOBAL
@@ -17,6 +17,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
router.Use(func(c *gin.Context) { router.Use(func(c *gin.Context) {
c.Set("database", database) c.Set("database", database)
c.Set("geoService", geoService) c.Set("geoService", geoService)
c.Set("s3Service", s3Service)
}) })
// ============================================ // ============================================
@@ -88,6 +89,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES // ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory) cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory)
// NOTATION LIVREUR
cartGroupV1.POST("/orders/:id/rate", handlers.SubmitLivreurRating)
cartGroupV1.GET("/orders/:id/rating", handlers.GetOrderRatingStatus)
// ⭐⭐ PÉNALITÉS CLIENT // ⭐⭐ PÉNALITÉS CLIENT
cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités
@@ -123,7 +128,12 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================ // ============================================
// 💸 WEBHOOK NOWPAYMENTS (v1) - PUBLIC (pas d'auth, vérifié par HMAC) // 💸 WEBHOOK NOWPAYMENTS (v1) - PUBLIC (pas d'auth, vérifié par HMAC)
// ============================================ // ============================================
router.POST("/api/v1/webhook/nowpayments", handlers.IPNWebhook) router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook)
// ============================================
// 🖼️ PROXY MÉDIAS (RustFS privé via VPN)
// ============================================
router.GET("/media/*key", handlers.ServeMedia)
// ============================================ // ============================================
// 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header) // 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header)
@@ -135,6 +145,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================ // ============================================
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink) router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
// ============================================
// 📋 HEALTH CHECK
// ============================================
router.GET("/health", handlers.Health)
// ============================================ // ============================================
// 📋 PATTERN v2: ADMIN API // 📋 PATTERN v2: ADMIN API
// ============================================ // ============================================
@@ -197,16 +212,18 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// CATÉGORIES - GESTION ADMIN // CATÉGORIES - GESTION ADMIN
// ============================================ // ============================================
adminGroupV2.POST("/categories", handlers.CreateCategory) adminGroupV2.POST("/categories", handlers.CreateCategory)
adminGroupV2.PUT("/categories/reorder", handlers.ReorderCategories)
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory) adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory) adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
// ============================================ // ============================================
// STATISTIQUES ADMIN // STATISTIQUES ADMIN
// ============================================ // ============================================
adminGroupV2.GET("/stats", handlers.GetAdminStats) adminGroupV2.GET("/stats", handlers.GetAdminStats)
adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats)
adminGroupV2.GET("/stats/monthly", handlers.GetAdminStatsByMonth)
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice) adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice) adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
adminGroupV2.GET("/stats/daily", handlers.GetAdminDailyDetail)
// ============================================ // ============================================
// COMMANDES - GESTION DE BASE // COMMANDES - GESTION DE BASE
// ============================================ // ============================================
@@ -260,6 +277,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin) adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin)
adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue) adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks) adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks)
adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings)
adminGroupV2.GET("/delivery-persons/:username/login-history", handlers.GetLivreurLoginHistory)
// Commandes annulées // Commandes annulées
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders) adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
// ============================================ // ============================================
@@ -392,6 +411,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// ============================================ // ============================================
// NOTIFICATIONS LIVREUR // NOTIFICATIONS LIVREUR
// ============================================ // ============================================
livreurGroupV1.GET("/ratings", handlers.GetMyRatings)
livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications) livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead) livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
+18 -39
View File
@@ -3,17 +3,13 @@ package services
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"gestion/utils"
"io" "io"
"math" "math"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"unicode"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
) )
// ============================================ // ============================================
@@ -64,25 +60,24 @@ func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionServi
} }
} }
// ============================================
// POINT D'ENTRÉE PRINCIPAL
// ============================================
// ResolveAddress tente de géocoder une adresse avec correction automatique.
// Retourne toujours une suggestion, même approximative.
// Ordre de résolution :
// 1. Géocodage exact → succès immédiat
// 2. Nominatim fuzzy search (addressdetails + limit=5)
// 3. Décomposition structurée de l'adresse
// 4. Erreur explicite avec suggestions si dispo
func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) { func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) {
rawAddress = strings.TrimSpace(rawAddress) rawAddress = strings.TrimSpace(rawAddress)
if rawAddress == "" { if rawAddress == "" {
return nil, fmt.Errorf("adresse vide") return nil, fmt.Errorf("adresse vide")
} }
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ── if loc, err := acs.geoService.getFromCache(rawAddress); err == nil {
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil { return &AddressSuggestion{
OriginalAddress: rawAddress,
CorrectedAddress: rawAddress,
Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude},
Confidence: 1.0,
CorrectionApplied: false,
Source: "exact",
}, nil
}
if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil {
acs.geoService.saveToCache(rawAddress, loc)
return &AddressSuggestion{ return &AddressSuggestion{
OriginalAddress: rawAddress, OriginalAddress: rawAddress,
CorrectedAddress: rawAddress, CorrectedAddress: rawAddress,
@@ -106,11 +101,6 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address
return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress) return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress)
} }
// ============================================
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
// ============================================
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) { func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
variants := buildAddressVariants(address) variants := buildAddressVariants(address)
@@ -131,7 +121,7 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
CorrectedAddress: corrected, CorrectedAddress: corrected,
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude}, Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
Confidence: confidence, Confidence: confidence,
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)), CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)),
Source: "fuzzy", Source: "fuzzy",
}, nil }, nil
} }
@@ -140,7 +130,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
return nil, fmt.Errorf("aucune correspondance fuzzy trouvée") return nil, fmt.Errorf("aucune correspondance fuzzy trouvée")
} }
// queryNominatim exécute une requête vers l'API Nominatim
func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) { func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) {
query = strings.TrimSpace(query) query = strings.TrimSpace(query)
if query == "" { if query == "" {
@@ -172,7 +161,7 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode) return nil, fmt.Errorf("nominatim status %d", resp.StatusCode)
} }
body, err := io.ReadAll(resp.Body) body, err := io.ReadAll(resp.Body)
@@ -196,7 +185,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) { func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
parts := parseAddressParts(address) parts := parseAddressParts(address)
// Essai 1 : numéro + rue + ville (sans code postal)
if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" { if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" {
q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city) q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city)
if s, err := acs.nominatimFuzzySearch(q); err == nil { if s, err := acs.nominatimFuzzySearch(q); err == nil {
@@ -206,7 +194,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
} }
} }
// Essai 2 : rue + code postal uniquement
if parts.streetName != "" && parts.postcode != "" { if parts.streetName != "" && parts.postcode != "" {
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode) q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
if s, err := acs.nominatimFuzzySearch(q); err == nil { if s, err := acs.nominatimFuzzySearch(q); err == nil {
@@ -216,7 +203,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
} }
} }
// Essai 3 : ville + code postal comme zone de repli
if parts.city != "" && parts.postcode != "" { if parts.city != "" && parts.postcode != "" {
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode) q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
suggestions, err := acs.queryNominatim(q, 3) suggestions, err := acs.queryNominatim(q, 3)
@@ -243,7 +229,7 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
// buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances // buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances
func buildAddressVariants(address string) []string { func buildAddressVariants(address string) []string {
variants := []string{address} variants := []string{address}
normalized := normalize(address) normalized := utils.NormalizeAddress(address)
// Variante sans accents // Variante sans accents
if normalized != address { if normalized != address {
@@ -387,8 +373,8 @@ func parseAddressParts(address string) addressParts {
// computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion // computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion
func computeConfidence(original, suggested string, nominatimImportance float64) float64 { func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
origNorm := normalize(strings.ToLower(original)) origNorm := utils.NormalizeAddress(strings.ToLower(original))
suggNorm := normalize(strings.ToLower(suggested)) suggNorm := utils.NormalizeAddress(strings.ToLower(suggested))
// Score de similarité sur les mots communs // Score de similarité sur les mots communs
origWords := strings.Fields(origNorm) origWords := strings.Fields(origNorm)
@@ -449,13 +435,6 @@ func formatNominatimAddress(s NominatimSuggestion) string {
return strings.Join(parts, ", ") return strings.Join(parts, ", ")
} }
// normalize supprime les accents et normalise les espaces
func normalize(s string) string {
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, _ := transform.String(t, s)
return strings.Join(strings.Fields(result), " ")
}
// isPostcode retourne true si le mot ressemble à un code postal français // isPostcode retourne true si le mot ressemble à un code postal français
func isPostcode(s string) bool { func isPostcode(s string) bool {
if len(s) != 5 { if len(s) != 5 {
+4 -19
View File
@@ -72,19 +72,14 @@ func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
return location, nil return location, nil
} }
// 2. TomTom (primaire — plus fiable que Nominatim pour les adresses FR) // 2. Tentative directe via Nominatim
if location, err := GeocodeWithTomTom(address); err == nil {
gs.saveToCache(address, location)
return location, nil
}
// 3. Fallback Nominatim
if location, err := gs.fetchFromNominatim(address); err == nil { if location, err := gs.fetchFromNominatim(address); err == nil {
gs.saveToCache(address, location) gs.saveToCache(address, location)
return location, nil return location, nil
} }
// 4. ── Correction automatique de l'adresse ──────────────────────────── // 3. ── NOUVEAU : correction automatique de l'adresse ──────────────────
// Déclenché uniquement si le géocodage direct a échoué.
log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address) log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address)
suggestion, err := gs.correctionService.ResolveAddress(address) suggestion, err := gs.correctionService.ResolveAddress(address)
@@ -216,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string {
return fmt.Sprintf("geocode:cache:%s", address) return fmt.Sprintf("geocode:cache:%s", address)
} }
// ============================================
// CALCULS GÉOGRAPHIQUES
// ============================================
// CalculateDistance calcule la distance entre deux points (formule Haversine) // CalculateDistance calcule la distance entre deux points (formule Haversine)
func CalculateDistance(from, to Coordinates) float64 { func CalculateDistance(from, to Coordinates) float64 {
// Conversion en radians // Conversion en radians
@@ -228,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 {
lat2Rad := toRadians(to.Latitude) lat2Rad := toRadians(to.Latitude)
lon2Rad := toRadians(to.Longitude) lon2Rad := toRadians(to.Longitude)
// Différences
dLat := lat2Rad - lat1Rad dLat := lat2Rad - lat1Rad
dLon := lon2Rad - lon1Rad dLon := lon2Rad - lon1Rad
@@ -244,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 {
// CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback) // CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback)
func CalculateETA(distanceKm float64) int { func CalculateETA(distanceKm float64) int {
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
if distanceKm < 0.1 { if distanceKm < 0.1 {
return MinETA // Très proche: minimum 3 minutes return MinETA
} }
// Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic)
// Plus réaliste que 30 km/h
travelTime := (distanceKm / 25.0) * 60.0 travelTime := (distanceKm / 25.0) * 60.0
// Ajouter une marge pour le trafic (environ 20%) // Ajouter une marge pour le trafic (environ 20%)
@@ -267,8 +254,6 @@ func CalculateETA(distanceKm float64) int {
return totalMinutes return totalMinutes
} }
// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel)
// Retourne (etaMinutes, distanceKm, error)
func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) { func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) {
if len(tomTomKeys.keys) == 0 { if len(tomTomKeys.keys) == 0 {
distance := CalculateDistance(from, to) distance := CalculateDistance(from, to)
+2 -2
View File
@@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool {
// EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte. // EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte.
// LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3). // LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3).
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error { func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
payload := map[string]interface{}{ payload := map[string]any{
"user_id": chatID, "user_id": chatID,
"username": username, "username": username,
"role": role, "role": role,
@@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro
// SendNotification envoie un message via la gateway LBTelegram. // SendNotification envoie un message via la gateway LBTelegram.
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn). // Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
func (s *LBTelegramService) SendNotification(userID int64, message string) error { func (s *LBTelegramService) SendNotification(userID int64, message string) error {
payload := map[string]interface{}{ payload := map[string]any{
"user_id": userID, "user_id": userID,
"message": message, "message": message,
} }
+135
View File
@@ -0,0 +1,135 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"mime/multipart"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/google/uuid"
)
type S3Service struct {
client *s3.Client
bucketName string
}
type S3Credentials struct {
S3KeyId string
S3AccessKey string
}
// NewS3Service initialise le client S3 pointant vers RustFS (accessible via VPN).
func NewS3Service(region, bucketName, endpoint string, creds S3Credentials) (*S3Service, error) {
var cfg aws.Config
var err error
if creds.S3KeyId != "" && creds.S3AccessKey != "" {
cfg, err = config.LoadDefaultConfig(context.TODO(),
config.WithRegion(region),
config.WithCredentialsProvider(
credentials.NewStaticCredentialsProvider(creds.S3KeyId, creds.S3AccessKey, ""),
),
)
} else {
cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
}
if err != nil {
return nil, fmt.Errorf("erreur chargement config AWS: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpoint != "" {
o.BaseEndpoint = aws.String(endpoint) // ex: http://10.x.x.x:9000 (IP interne VPN de RustFS)
o.UsePathStyle = true
}
})
return &S3Service{
client: client,
bucketName: bucketName,
}, nil
}
// UploadFile upload un fichier et renvoie sa clé S3 (pas d'URL publique, RustFS est privé).
func (s *S3Service) UploadFile(fileHeader *multipart.FileHeader, folder string) (key string, err error) {
ext := filepath.Ext(fileHeader.Filename)
fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext)
return s.UploadFileWithName(fileHeader, folder, fileName)
}
// UploadFileWithName upload un fichier avec un nom déjà déterminé et renvoie la clé S3.
func (s *S3Service) UploadFileWithName(fileHeader *multipart.FileHeader, folder, fileName string) (key string, err error) {
file, err := fileHeader.Open()
if err != nil {
return "", fmt.Errorf("erreur ouverture fichier: %w", err)
}
defer file.Close()
buf := bytes.NewBuffer(nil)
if _, err := buf.ReadFrom(file); err != nil {
return "", fmt.Errorf("erreur lecture fichier: %w", err)
}
key = fmt.Sprintf("%s/%s", folder, fileName)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
contentType := fileHeader.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
Body: bytes.NewReader(buf.Bytes()),
ContentType: aws.String(contentType),
})
if err != nil {
return "", fmt.Errorf("erreur upload RustFS: %w", err)
}
return key, nil
}
// GetFile récupère un objet depuis RustFS (stream + content-type) pour le proxy.
// Le contexte doit rester actif pendant toute la lecture du body par l'appelant.
func (s *S3Service) GetFile(ctx context.Context, key string) (io.ReadCloser, string, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
})
if err != nil {
return nil, "", fmt.Errorf("erreur lecture RustFS: %w", err)
}
contentType := "application/octet-stream"
if out.ContentType != nil {
contentType = *out.ContentType
}
return out.Body, contentType, nil
}
// DeleteFile supprime un fichier à partir de sa clé S3.
func (s *S3Service) DeleteFile(key string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
})
if err != nil {
return fmt.Errorf("erreur suppression RustFS: %w", err)
}
return nil
}
+105
View File
@@ -0,0 +1,105 @@
package services
import (
"fmt"
"gestion/utils"
"io"
"mime/multipart"
"os"
"path/filepath"
)
// Storage abstrait l'emplacement de stockage des médias produits (local ou S3),
// pour que tous les points d'upload/suppression respectent le même driver.
type Storage interface {
// Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL)
// et la clé interne (vide pour local, clé S3 sinon — models.Media.Key).
Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error)
// Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média :
// chaque implémentation ignore celui qui ne la concerne pas.
Delete(url string, key string) error
}
// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads").
type LocalStorage struct {
baseDir string
}
func NewLocalStorage(baseDir string) *LocalStorage {
return &LocalStorage{baseDir: baseDir}
}
func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
destFolder := filepath.Join(s.baseDir, folder)
if err := os.MkdirAll(destFolder, 0750); err != nil {
return "", "", fmt.Errorf("erreur création dossier: %w", err)
}
filePath := filepath.Join(destFolder, fileName)
safeFilePath, err := utils.SanitizeFilePath(filePath)
if err != nil {
return "", "", fmt.Errorf("chemin invalide: %w", err)
}
src, err := fileHeader.Open()
if err != nil {
return "", "", fmt.Errorf("erreur ouverture fichier: %w", err)
}
defer src.Close()
dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640)
if err != nil {
return "", "", fmt.Errorf("erreur création fichier: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
os.Remove(safeFilePath)
return "", "", fmt.Errorf("erreur écriture fichier: %w", err)
}
return "/" + filepath.ToSlash(safeFilePath), "", nil
}
func (s *LocalStorage) Delete(url string, key string) error {
filePath := ""
if len(url) > 0 && url[0] == '/' {
filePath = url[1:]
} else {
filePath = url
}
safeFilePath, err := utils.SanitizeFilePath(filePath)
if err != nil {
return fmt.Errorf("chemin invalide: %w", err)
}
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("erreur suppression: %w", err)
}
return nil
}
// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage.
type S3Storage struct {
s3 *S3Service
}
func NewS3Storage(s3 *S3Service) *S3Storage {
return &S3Storage{s3: s3}
}
func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) {
key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName)
if err != nil {
return "", "", err
}
return "/media/" + key, key, nil
}
func (s *S3Storage) Delete(url string, key string) error {
if key == "" {
return fmt.Errorf("clé S3 manquante pour suppression")
}
return s.s3.DeleteFile(key)
}
+4 -4
View File
@@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
return fmt.Errorf("telegram non configuré") return fmt.Errorf("telegram non configuré")
} }
payload := map[string]interface{}{ payload := map[string]any{
"chat_id": chatID, "chat_id": chatID,
"text": text, "text": text,
"parse_mode": "HTML", "parse_mode": "HTML",
@@ -107,11 +107,11 @@ func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, butt
row = append(row, map[string]string{"text": b[0], "url": b[1]}) row = append(row, map[string]string{"text": b[0], "url": b[1]})
} }
payload := map[string]interface{}{ payload := map[string]any{
"chat_id": chatID, "chat_id": chatID,
"text": text, "text": text,
"parse_mode": "HTML", "parse_mode": "HTML",
"reply_markup": map[string]interface{}{ "reply_markup": map[string]any{
"inline_keyboard": [][]map[string]string{row}, "inline_keyboard": [][]map[string]string{row},
}, },
} }
@@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
return fmt.Errorf("telegram non configuré") return fmt.Errorf("telegram non configuré")
} }
payload := map[string]interface{}{ payload := map[string]any{
"url": webhookURL, "url": webhookURL,
"allowed_updates": []string{"message"}, "allowed_updates": []string{"message"},
} }
-66
View File
@@ -15,72 +15,6 @@ import (
"time" "time"
) )
// GeocodeWithTomTom géocode une adresse via l'API TomTom Search.
func GeocodeWithTomTom(address string) (*GeoLocation, error) {
client := &http.Client{Timeout: 10 * time.Second}
buildReq := func(key string) (*http.Request, error) {
u := &url.URL{
Scheme: "https",
Host: "api.tomtom.com",
Path: fmt.Sprintf("/search/2/geocode/%s.json", url.PathEscape(address)),
}
q := url.Values{}
q.Set("key", key)
q.Set("countrySet", "FR")
q.Set("limit", "1")
u.RawQuery = q.Encode()
return http.NewRequest(http.MethodGet, u.String(), nil)
}
resp, err := tomTomKeys.Do(client, buildReq)
if err != nil {
return nil, fmt.Errorf("TomTom geocode: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("TomTom geocode %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("TomTom geocode lecture: %w", err)
}
var parsed struct {
Results []struct {
Position struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
} `json:"position"`
Address struct {
FreeformAddress string `json:"freeformAddress"`
} `json:"address"`
MatchConfidence struct {
Score float64 `json:"score"`
} `json:"matchConfidence"`
} `json:"results"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, fmt.Errorf("TomTom geocode parsing: %w", err)
}
if len(parsed.Results) == 0 {
return nil, fmt.Errorf("TomTom geocode: aucun résultat pour '%s'", address)
}
r := parsed.Results[0]
log.Printf("📍 [GEO] TomTom geocode '%s' → %s (%.6f, %.6f) conf=%.2f",
address, r.Address.FreeformAddress, r.Position.Lat, r.Position.Lon, r.MatchConfidence.Score)
return &GeoLocation{
Latitude: r.Position.Lat,
Longitude: r.Position.Lon,
DisplayName: r.Address.FreeformAddress,
}, nil
}
func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) { func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
+2 -5
View File
@@ -59,6 +59,7 @@ func (m *tomTomKeyManager) rotate(fromIdx int) {
} }
// Do exécute la requête en rotant automatiquement sur 403/429. // Do exécute la requête en rotant automatiquement sur 403/429.
// buildReq doit construire une nouvelle *http.Request pour la clé donnée.
func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) { func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) {
n := len(m.keys) n := len(m.keys)
if n == 0 { if n == 0 {
@@ -67,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h
_, startIdx := m.currentKey() _, startIdx := m.currentKey()
for attempt := 0; attempt < n; attempt++ { for attempt := range n {
idx := (startIdx + attempt) % n idx := (startIdx + attempt) % n
key := m.keys[idx] key := m.keys[idx]
req, err := buildReq(key) req, err := buildReq(key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
io.Copy(io.Discard, resp.Body) io.Copy(io.Discard, resp.Body)
resp.Body.Close() resp.Body.Close()
m.rotate(idx) m.rotate(idx)
continue continue
} }
return resp, nil return resp, nil
} }
@@ -0,0 +1,126 @@
package tests
import (
"gestion/db"
"gestion/services"
"testing"
"time"
)
// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à
// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests
// purs de services/adresses_correction_test.go (normalisation, décomposition,
// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout
// de ResolveAddress sur de vraies adresses nantaises mal écrites.
//
// Chaque cas a été vérifié manuellement au préalable (curl vers l'API
// Nominatim) pour confirmer ce que la recherche directe résout déjà seule
// (Nominatim tolère nativement la casse, les accents et certaines
// abréviations sans point) et ce qui nécessite réellement la logique de
// correction (variantes, décomposition structurée, repli ville+code postal).
//
// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à
// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie
// envers le service public.
const (
nantesLatMin, nantesLatMax = 47.15, 47.28
nantesLonMin, nantesLonMax = -1.65, -1.45
)
func isWithinNantes(lat, lon float64) bool {
return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax
}
func TestResolveAddress_RealNantesAddresses(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
correction := services.NewAddressCorrectionService(geoService)
cases := []struct {
name string
input string
minConfidence float64
maxConfidence float64
}{
{
// Nominatim tolère nativement la casse et l'absence d'accent :
// résolution directe (étape 1 de ResolveAddress), confiance max.
name: "tout minuscule sans accent",
input: "12 rue crebillon 44000 nantes",
minConfidence: 0.90,
maxConfidence: 1.0,
},
{
// Abréviation sans point ("Pl" au lieu de "Place") — également
// tolérée nativement par Nominatim, résolution directe.
name: "abréviation sans point",
input: "3 Pl Royale 44000 Nantes",
minConfidence: 0.90,
maxConfidence: 1.0,
},
{
// Faute de frappe réaliste sur un nom de rue réel (Gambetta ->
// Gambeta) : vérifié que la recherche Nominatim directe ET
// toutes les variantes générées par l'algorithme (accents,
// abréviations, décomposition structurée essais 1 et 2)
// échouent — seul le repli ville+code postal (essai 3,
// confiance fixe 0.30) aboutit. Documente une vraie limite :
// l'algorithme ne corrige pas les fautes de frappe arbitraires
// dans un nom de rue, il retombe sur "quelque part dans la
// bonne ville".
name: "faute de frappe non corrigible sur le nom de rue",
input: "15 Rue Gambeta 44000 Nantes",
minConfidence: 0.25,
maxConfidence: 0.35,
},
}
for i, c := range cases {
t.Run(c.name, func(t *testing.T) {
if i > 0 {
time.Sleep(1200 * time.Millisecond)
}
suggestion, err := correction.ResolveAddress(c.input)
if err != nil {
t.Fatalf("ResolveAddress(%q): %v", c.input, err)
}
if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) {
t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f",
c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
}
if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence {
t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]",
c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence)
}
if suggestion.CorrectedAddress == "" {
t.Errorf("adresse corrigée vide pour %q", c.input)
}
t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)",
c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source,
suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude)
})
}
}
// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit
// échouer proprement plutôt que renvoyer une coordonnée aléatoire.
func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) {
if testing.Short() {
t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
}
geoService := services.NewGeoService(db.Redis, db.RedisCtx)
correction := services.NewAddressCorrectionService(geoService)
_, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart")
if err == nil {
t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel")
}
}
@@ -0,0 +1,243 @@
package tests
import (
"gestion/models"
"testing"
)
// db_address.go gère une table de correspondances gérées par l'admin
// (adresse_correction) : à chaque checkout, CheckAddress vérifie si l'adresse
// saisie par le client correspond à une entrée connue comme invalide, et si
// oui, substitue l'adresse correcte tout en signalant une erreur pour forcer
// une nouvelle confirmation côté client (voir ValidateBasket).
func cleanupAddressCorrections(t *testing.T, ids ...int64) {
t.Helper()
t.Cleanup(func() {
for _, id := range ids {
testDB.GDB.Exec(`DELETE FROM adresse_correction WHERE id = ?`, id)
}
})
}
func TestCheckAddress_NoMatchReturnsNilAndLeavesAddressUnchanged(t *testing.T) {
cmd := &models.Command{DeliveryAddress: testUserPrefix + "adresse jamais enregistrée 44000 Nantes"}
original := cmd.DeliveryAddress
if err := testDB.CheckAddress(cmd); err != nil {
t.Fatalf("CheckAddress sans correspondance ne doit jamais échouer: %v", err)
}
if cmd.DeliveryAddress != original {
t.Errorf("adresse ne doit pas être modifiée sans correspondance: got=%q want=%q", cmd.DeliveryAddress, original)
}
}
func TestCheckAddress_MatchSubstitutesCorrectAddressAndReturnsError(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress: %v", err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
cmd := &models.Command{DeliveryAddress: invalid}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (pour forcer une re-confirmation client)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
// addCorrectionForFallback enregistre une correction et retourne une fonction
// de nettoyage à appeler via t.Cleanup par l'appelant (évite de dépendre de
// l'ordre d'exécution entre plusieurs corrections ajoutées dans un même test).
func addCorrectionForFallback(t *testing.T, invalid, correct string) {
t.Helper()
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress(%q -> %q): %v", invalid, correct, err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
}
// Les quatre tests suivants couvrent le fallback normalisé de CheckAddress
// (utils.NormalizeAddress + strings.EqualFold) : une correction enregistrée
// par l'admin avec un texte exact donné doit continuer à s'appliquer même si
// le client tape une variante mineure (casse, accents, espaces), plutôt que
// d'échouer silencieusement et laisser passer une adresse non livrable.
func TestCheckAddress_NormalizedFallback_CaseVariantMatches(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "12 RUE CREBILLON NANTES"}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (variante de casse)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_AccentVariantMatches(t *testing.T) {
invalid := testUserPrefix + "10 Rue du Général Buat Nantes"
correct := testUserPrefix + "10 Rue du Général Buat, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
// Saisie sans accent par le client, alors que la correction enregistrée
// par l'admin en contient un.
cmd := &models.Command{DeliveryAddress: testUserPrefix + "10 Rue du General Buat Nantes"}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (variante d'accent)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_WhitespaceVariantMatches(t *testing.T) {
invalid := testUserPrefix + "5 Cours des 50 Otages Nantes"
correct := testUserPrefix + "5 Cours des 50 Otages, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "5 Cours des 50 Otages Nantes "}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (espaces multiples)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
func TestCheckAddress_NormalizedFallback_CombinedCaseAccentWhitespaceMatches(t *testing.T) {
invalid := testUserPrefix + "8 Rue de Verdun Nantes"
correct := testUserPrefix + "8 Rue de Verdun, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "8 RUE de verdun nantes "}
err := testDB.CheckAddress(cmd)
if err == nil {
t.Fatal("attendu une erreur signalant la correction (casse + espaces combinés)")
}
if cmd.DeliveryAddress != correct {
t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct)
}
}
// Le fallback compare une égalité normalisée stricte, pas une similarité
// floue : une adresse réellement différente (même partiellement proche) ne
// doit jamais être substituée par erreur.
func TestCheckAddress_NormalizedFallback_DoesNotMatchDifferentAddress(t *testing.T) {
invalid := testUserPrefix + "12 Rue Crebillon Nantes"
correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes"
addCorrectionForFallback(t, invalid, correct)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "14 Rue Crebillon Nantes"}
original := cmd.DeliveryAddress
if err := testDB.CheckAddress(cmd); err != nil {
t.Fatalf("une adresse différente ne doit pas déclencher de correction: %v", err)
}
if cmd.DeliveryAddress != original {
t.Errorf("adresse ne doit pas être modifiée: got=%q want=%q", cmd.DeliveryAddress, original)
}
}
// Avec plusieurs corrections enregistrées, le fallback doit retrouver la
// bonne entrée (pas la première venue) même via une variante normalisée.
func TestCheckAddress_NormalizedFallback_FindsRightEntryAmongMultiple(t *testing.T) {
invalidA := testUserPrefix + "1 Rue A Nantes"
correctA := testUserPrefix + "1 Rue A, 44000 Nantes"
invalidB := testUserPrefix + "2 Rue B Nantes"
correctB := testUserPrefix + "2 Rue B, 44000 Nantes"
addCorrectionForFallback(t, invalidA, correctA)
addCorrectionForFallback(t, invalidB, correctB)
cmd := &models.Command{DeliveryAddress: testUserPrefix + "2 RUE b nantes"}
if err := testDB.CheckAddress(cmd); err == nil {
t.Fatal("attendu une erreur signalant la correction B")
}
if cmd.DeliveryAddress != correctB {
t.Errorf("adresse corrigée: got=%q want=%q (ne doit pas confondre avec A)", cmd.DeliveryAddress, correctB)
}
}
func TestAddAddress_ThenAllAddressIncludesIt(t *testing.T) {
invalid := testUserPrefix + "adresse invalide test"
correct := testUserPrefix + "adresse correcte test"
if err := testDB.AddAddress(correct, invalid); err != nil {
t.Fatalf("AddAddress: %v", err)
}
var id int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id)
cleanupAddressCorrections(t, id)
all, err := testDB.AllAddress()
if err != nil {
t.Fatalf("AllAddress: %v", err)
}
found := false
for _, a := range all {
if a.InvalidAddress == invalid && a.CorrectAddress == correct {
found = true
break
}
}
if !found {
t.Errorf("la correspondance ajoutée n'apparaît pas dans AllAddress")
}
}
// DeleteAddress doit cibler la correspondance exacte, sans affecter une autre
// correspondance non liée. Note : invalid_address a une contrainte UNIQUE en
// base (adresse_correction_invalid_address_key), donc deux corrections ne
// peuvent jamais partager la même adresse invalide — le risque réel est
// seulement qu'un DELETE mal ciblé touche une correspondance différente.
func TestDeleteAddress_RemovesOnlyTargetedPairNotUnrelatedOne(t *testing.T) {
invalidA := testUserPrefix + "adresse A"
correctA := testUserPrefix + "correction A"
invalidB := testUserPrefix + "adresse B"
correctB := testUserPrefix + "correction B"
if err := testDB.AddAddress(correctA, invalidA); err != nil {
t.Fatalf("AddAddress A: %v", err)
}
if err := testDB.AddAddress(correctB, invalidB); err != nil {
t.Fatalf("AddAddress B: %v", err)
}
var idA, idB int64
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidA).Scan(&idA)
testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidB).Scan(&idB)
cleanupAddressCorrections(t, idA, idB)
if err := testDB.DeleteAddress(invalidA, correctA); err != nil {
t.Fatalf("DeleteAddress: %v", err)
}
all, err := testDB.AllAddress()
if err != nil {
t.Fatalf("AllAddress: %v", err)
}
var stillHasA, stillHasB bool
for _, a := range all {
if a.InvalidAddress == invalidA && a.CorrectAddress == correctA {
stillHasA = true
}
if a.InvalidAddress == invalidB && a.CorrectAddress == correctB {
stillHasB = true
}
}
if stillHasA {
t.Error("la correspondance ciblée (A) doit être supprimée")
}
if !stillHasB {
t.Error("l'autre correspondance (B), non ciblée, ne doit pas être supprimée")
}
}
+273
View File
@@ -0,0 +1,273 @@
package tests
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func alertContext(username, role string, body []byte, alertID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/livreur/alert", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
if username != "" {
c.Set("username", username)
}
c.Set("role", role)
if alertID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", alertID)}}
}
return c, rec
}
func createTestAlert(t *testing.T, username, message string) int {
t.Helper()
alert, err := testDB.CreateAlert(username, message)
if err != nil {
t.Fatalf("CreateAlert: %v", err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE id = ?`, alert.ID)
})
return alert.ID
}
// ── AlertPolice ──────────────────────────────────────────────────────────────
func TestAlertPolice_LivreurCreatesAlert(t *testing.T) {
livreur := testUserPrefix + "alert_create_livreur"
body, _ := json.Marshal(map[string]string{"message": "Contrôle en cours"})
c, rec := alertContext(livreur, "livreur", body, 0)
handlers.AlertPolice(c)
t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE username = ?`, livreur) })
if rec.Code != http.StatusCreated {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
AlertID int `json:"alert_id"`
User string `json:"user"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.User != livreur {
t.Errorf("user: got=%q want=%q", resp.User, livreur)
}
alert, err := testDB.GetAlertPolicy(resp.AlertID)
if err != nil {
t.Fatalf("GetAlertPolicy: %v", err)
}
if alert.Message != "Contrôle en cours" || alert.Status != "true" {
t.Errorf("alerte créée: message=%q status=%q", alert.Message, alert.Status)
}
}
func TestAlertPolice_NonLivreurForbidden(t *testing.T) {
for _, role := range []string{"client", "admin", "cabine"} {
t.Run(role, func(t *testing.T) {
body, _ := json.Marshal(map[string]string{"message": "test"})
c, rec := alertContext(testUserPrefix+"alert_forbidden_"+role, role, body, 0)
handlers.AlertPolice(c)
if rec.Code != http.StatusForbidden {
t.Errorf("le rôle %q ne doit pas pouvoir déclencher une alerte police: got=%d", role, rec.Code)
}
})
}
}
// ── GetAlert ─────────────────────────────────────────────────────────────────
func TestGetAlert_LivreurCanViewOwnAlert(t *testing.T) {
livreur := testUserPrefix + "alert_view_own"
alertID := createTestAlert(t, livreur, "test")
c, rec := alertContext(livreur, "livreur", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAlert_LivreurCannotViewOthersAlert(t *testing.T) {
owner := testUserPrefix + "alert_view_owner"
intruder := testUserPrefix + "alert_view_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur ne doit pas pouvoir consulter l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAlert_AdminCanViewAnyAlert(t *testing.T) {
owner := testUserPrefix + "alert_view_admin_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_view_admin", "admin", nil, alertID)
handlers.GetAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("un admin doit pouvoir consulter n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── EndAlert ─────────────────────────────────────────────────────────────────
func TestEndAlert_OwnerCanEnd(t *testing.T) {
livreur := testUserPrefix + "alert_end_owner"
alertID := createTestAlert(t, livreur, "test")
c, rec := alertContext(livreur, "livreur", nil, alertID)
handlers.EndAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
alert, _ := testDB.GetAlertPolicy(alertID)
if alert.Status != "false" {
t.Errorf("statut après EndAlert: got=%q want=false", alert.Status)
}
}
func TestEndAlert_NonOwnerLivreurRejected(t *testing.T) {
owner := testUserPrefix + "alert_end_owner2"
intruder := testUserPrefix + "alert_end_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.EndAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur tiers ne doit pas pouvoir terminer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
alert, _ := testDB.GetAlertPolicy(alertID)
if alert.Status != "true" {
t.Errorf("l'alerte ne doit pas être terminée par un intrus: got=%q want=true", alert.Status)
}
}
// ── DeleteAlert ──────────────────────────────────────────────────────────────
//
// Corrigé : un livreur ne peut supprimer que ses propres alertes (comme
// EndAlert) ; un admin garde l'accès complet sans restriction de propriétaire.
func TestDeleteAlert_OwnerLivreurCanDeleteOwnAlert(t *testing.T) {
owner := testUserPrefix + "alert_delete_owner_ok"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(owner, "livreur", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("le propriétaire doit pouvoir supprimer sa propre alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
t.Error("l'alerte doit être supprimée")
}
}
func TestDeleteAlert_NonOwnerLivreurRejected(t *testing.T) {
owner := testUserPrefix + "alert_delete_owner"
intruder := testUserPrefix + "alert_delete_intruder"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(intruder, "livreur", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur tiers ne doit pas pouvoir supprimer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err != nil {
t.Error("l'alerte ne doit pas être supprimée par un intrus")
}
}
func TestDeleteAlert_AdminCanDeleteAnyAlertRegardlessOfOwner(t *testing.T) {
owner := testUserPrefix + "alert_delete_admin_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_delete_admin", "admin", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusOK {
t.Fatalf("un admin doit pouvoir supprimer n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String())
}
if _, err := testDB.GetAlertPolicy(alertID); err == nil {
t.Error("l'alerte doit être supprimée par l'admin")
}
}
func TestDeleteAlert_NonLivreurNonAdminForbidden(t *testing.T) {
owner := testUserPrefix + "alert_delete_forbidden_owner"
alertID := createTestAlert(t, owner, "test")
c, rec := alertContext(testUserPrefix+"alert_delete_forbidden_cabine", "cabine", nil, alertID)
handlers.DeleteAlert(c)
if rec.Code != http.StatusForbidden {
t.Errorf("le rôle cabine ne doit pas pouvoir supprimer une alerte: got=%d", rec.Code)
}
}
// ── Listing ──────────────────────────────────────────────────────────────────
func TestGetMyAlerts_ReturnsOnlyOwnAlerts(t *testing.T) {
mine := testUserPrefix + "alert_mine"
other := testUserPrefix + "alert_other"
createTestAlert(t, mine, "à moi 1")
createTestAlert(t, mine, "à moi 2")
createTestAlert(t, other, "pas à moi")
c, rec := alertContext(mine, "livreur", nil, 0)
handlers.GetMyAlerts(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Count int `json:"count"`
}
json.Unmarshal(rec.Body.Bytes(), &resp)
if resp.Count != 2 {
t.Errorf("nombre d'alertes du livreur: got=%d want=2", resp.Count)
}
}
func TestGetActiveAlerts_ExcludesEndedAlerts(t *testing.T) {
livreur := testUserPrefix + "alert_active_filter"
activeID := createTestAlert(t, livreur, "active")
endedID := createTestAlert(t, livreur, "terminée")
if err := testDB.EndAlert(endedID); err != nil {
t.Fatalf("EndAlert (setup): %v", err)
}
alerts, err := testDB.GetActiveAlerts()
if err != nil {
t.Fatalf("GetActiveAlerts: %v", err)
}
var foundActive, foundEnded bool
for _, a := range alerts {
if a.ID == activeID {
foundActive = true
}
if a.ID == endedID {
foundEnded = true
}
}
if !foundActive {
t.Error("l'alerte active doit apparaître dans GetActiveAlerts")
}
if foundEnded {
t.Error("l'alerte terminée ne doit pas apparaître dans GetActiveAlerts")
}
}
@@ -0,0 +1,256 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func cmdContext(method, username, role string, body []byte, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
var reader *bytes.Reader
if body != nil {
reader = bytes.NewReader(body)
} else {
reader = bytes.NewReader([]byte{})
}
req := httptest.NewRequest(method, "/api/v1/commands", reader)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
if username != "" {
c.Set("username", username)
}
c.Set("role", role)
if commandID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
}
return c, rec
}
// ── CancelCommandByClient (HTTP layer) ───────────────────────────────────
func TestCancelCommandByClient_RejectsNonClientRole(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodPost, testUserPrefix+"cancel_role", "admin", nil, 1)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role admin doit être refusé: got=%d want=403", rec.Code)
}
}
func TestCancelCommandByClient_InvalidCommandIDReturns400(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_badid")
c, rec := cmdContext(http.MethodPost, username, "client", nil, 0)
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
}
}
func TestCancelCommandByClient_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_404")
c, rec := cmdContext(http.MethodPost, username, "client", nil, 99999999)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_WrongOwnerReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "cancel_owner")
intruder := newTestClient(t, "cancel_intruder")
productID := newTestProduct(t, "CancelWrongOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, intruder, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas pouvoir annuler: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_SuccessNoPenaltyWhenNoLivreur(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_ok_nopenalty")
productID := newTestProduct(t, "CancelOkNoPenalty", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusOK {
t.Fatalf("annulation sans livreur doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation: got=%s want=cancelled", got)
}
}
func TestCancelCommandByClient_TerminalStatusReturns400(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_terminal")
productID := newTestProduct(t, "CancelTerminal", 10)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("annulation d'une commande livrée doit être rejetée: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestCancelCommandByClient_ConfirmationRequiredReturns409WithPenaltyWarning(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_confirm")
livreur := "cancel_confirm_livreur"
productID := newTestProduct(t, "CancelConfirm", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", livreur, productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusConflict {
t.Fatalf("annulation tardive sans force doit demander confirmation: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"will_apply":true`)) {
t.Errorf("la réponse doit avertir d'une pénalité à venir: body=%s", rec.Body.String())
}
// Statut inchangé tant que non confirmé.
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("statut ne doit pas changer avant confirmation: got=%s want=en_route", got)
}
}
// ── GetMyCancellationHistory ──────────────────────────────────────────────
func TestGetMyCancellationHistory_RejectsNonClientRole(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"hist_role", "livreur", nil, 0)
handlers.GetMyCancellationHistory(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetMyCancellationHistory_ReturnsHistoryAndTotalPenalties(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "hist_ok")
c, rec := cmdContext(http.MethodGet, username, "client", nil, 0)
handlers.GetMyCancellationHistory(c)
if rec.Code != http.StatusOK {
t.Fatalf("historique doit réussir pour un client: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"total_penalties"`)) {
t.Errorf("la réponse doit inclure total_penalties: body=%s", rec.Body.String())
}
}
// ── GetAllCancelledOrders ─────────────────────────────────────────────────
func TestGetAllCancelledOrders_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_role", "livreur", nil, 0)
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetAllCancelledOrders_InvalidLimitFallsBackToDefault(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_limit", "admin", nil, 0)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=not-a-number", nil)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"allcancel_limit")
c.Set("role", "admin")
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusOK {
t.Fatalf("limit invalide doit quand même réussir avec un défaut: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetAllCancelledOrders_LimitCapsAt500(t *testing.T) {
cleanupStockTestData(t)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=99999", nil)
c.Set("database", testDB)
c.Set("username", testUserPrefix+"allcancel_cap")
c.Set("role", "admin")
handlers.GetAllCancelledOrders(c)
if rec.Code != http.StatusOK {
t.Fatalf("limit énorme doit quand même réussir (plafonné): got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── DeleteCommandByCabine ─────────────────────────────────────────────────
func TestDeleteCommandByCabine_RejectsNonCabineNonAdmin(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_role", "client", nil, 1)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestDeleteCommandByCabine_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_404", "cabine", nil, 99999999)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestDeleteCommandByCabine_SuccessDeletesCommand(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delcab_ok")
productID := newTestProduct(t, "DelCabOk", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_ok_actor", "cabine", nil, cmdID)
handlers.DeleteCommandByCabine(c)
if rec.Code != http.StatusOK {
t.Fatalf("suppression par cabine doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count)
if count != 0 {
t.Errorf("la commande doit être supprimée: got=%d lignes restantes", count)
}
}
// ── validateReason (testé indirectement via CancelCommandByClient) ───────
func TestCancelCommandByClient_BlankReasonDefaultsToStandardMessage(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reason_blank")
productID := newTestProduct(t, "ReasonBlank", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
body := []byte(`{"reason":""}`)
c, rec := cmdContext(http.MethodPost, username, "client", body, cmdID)
handlers.CancelCommandByClient(c)
if rec.Code != http.StatusOK {
t.Fatalf("annulation avec reason vide doit réussir (validateReason doit fournir un défaut, pas rejeter): got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation avec raison vide: got=%s want=cancelled", got)
}
}
@@ -0,0 +1,210 @@
package tests
import "testing"
// commandAddressState lit adresse/proposed_address/address_proposal_status
// directement pour vérifier le flux propose -> respond.
type commandAddressState struct {
Adresse string `gorm:"column:adresse"`
ProposedAddress string `gorm:"column:proposed_address"`
AddressProposalStatus string `gorm:"column:address_proposal_status"`
}
func getCommandAddressState(t *testing.T, commandID int) commandAddressState {
t.Helper()
var s commandAddressState
if err := testDB.GDB.Raw(
`SELECT adresse, COALESCE(proposed_address, '') as proposed_address,
COALESCE(address_proposal_status, '') as address_proposal_status
FROM commandes WHERE id = ?`, commandID,
).Scan(&s).Error; err != nil {
t.Fatalf("getCommandAddressState: %v", err)
}
return s
}
// ── UpdateCommandAddress (modification directe admin) ───────────────────────
func TestUpdateCommandAddress_UpdatesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "upd_addr_ok")
productID := newTestProduct(t, "UpdAddrOk", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
if err := testDB.UpdateCommandAddress(cmdID, "42 Nouvelle Adresse, 44000 Nantes"); err != nil {
t.Fatalf("UpdateCommandAddress: %v", err)
}
if got := getCommandAddressState(t, cmdID).Adresse; got != "42 Nouvelle Adresse, 44000 Nantes" {
t.Errorf("adresse après mise à jour: got=%q", got)
}
}
func TestUpdateCommandAddress_RejectsEmptyAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "upd_addr_empty")
productID := newTestProduct(t, "UpdAddrEmpty", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
before := getCommandAddressState(t, cmdID).Adresse
if err := testDB.UpdateCommandAddress(cmdID, " "); err == nil {
t.Fatal("attendu un rejet pour une adresse vide/blanche")
}
if got := getCommandAddressState(t, cmdID).Adresse; got != before {
t.Errorf("adresse ne doit pas changer sur un rejet: got=%q want=%q", got, before)
}
}
func TestUpdateCommandAddress_RejectsUnknownCommand(t *testing.T) {
if err := testDB.UpdateCommandAddress(999999999, "1 rue inexistante"); err == nil {
t.Fatal("attendu une erreur pour une commande inexistante")
}
}
// Note : db.UpdateCommandAddress lui-même n'interdit pas de modifier l'adresse
// d'une commande terminée — cette règle ("livre/approved/cancelled interdits")
// est uniquement appliquée par le handler HTTP (UpdateCommandAddress dans
// handlers/commands.go), pas par la fonction DB. Ce test documente ce fait
// explicitement pour qu'un futur appelant direct de la fonction DB (ex. un
// script, un worker) ne suppose pas à tort que la protection est là.
func TestUpdateCommandAddress_DBFunctionAloneDoesNotBlockTerminalStatuses(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "upd_addr_terminal")
productID := newTestProduct(t, "UpdAddrTerminal", 10)
cmdID := newTestCommandWithItem(t, username, "approved", "", productID, 1, 10)
if err := testDB.UpdateCommandAddress(cmdID, "Adresse modifiée après coup"); err != nil {
t.Fatalf("la fonction DB seule n'impose pas la restriction de statut (attendu, voir commentaire): %v", err)
}
if got := getCommandAddressState(t, cmdID).Adresse; got != "Adresse modifiée après coup" {
t.Errorf("adresse: got=%q", got)
}
}
// ── ProposeAddressChange / RespondToAddressProposal ─────────────────────────
func TestProposeAddressChange_SetsProposedAddressAndPendingStatus(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "propose_addr_ok")
productID := newTestProduct(t, "ProposeAddrOk", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
if err := testDB.ProposeAddressChange(cmdID, "Nouvelle adresse proposée, 44000 Nantes", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
s := getCommandAddressState(t, cmdID)
if s.ProposedAddress != "Nouvelle adresse proposée, 44000 Nantes" {
t.Errorf("proposed_address: got=%q", s.ProposedAddress)
}
if s.AddressProposalStatus != "pending" {
t.Errorf("address_proposal_status: got=%q want=pending", s.AddressProposalStatus)
}
}
func TestRespondToAddressProposal_AcceptedAppliesProposedAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_accept")
productID := newTestProduct(t, "RespondAddrAccept", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée acceptée", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil {
t.Fatalf("RespondToAddressProposal (accepté): %v", err)
}
s := getCommandAddressState(t, cmdID)
if s.Adresse != "Adresse proposée acceptée" {
t.Errorf("adresse de livraison après acceptation: got=%q want=%q", s.Adresse, "Adresse proposée acceptée")
}
if s.ProposedAddress != "" {
t.Errorf("proposed_address doit être vidé après réponse: got=%q", s.ProposedAddress)
}
if s.AddressProposalStatus != "accepted" {
t.Errorf("address_proposal_status: got=%q want=accepted", s.AddressProposalStatus)
}
}
func TestRespondToAddressProposal_RejectedKeepsOriginalAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_reject")
productID := newTestProduct(t, "RespondAddrReject", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
original := getCommandAddressState(t, cmdID).Adresse
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée refusée", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, username, false); err != nil {
t.Fatalf("RespondToAddressProposal (refusé): %v", err)
}
s := getCommandAddressState(t, cmdID)
if s.Adresse != original {
t.Errorf("l'adresse de livraison ne doit pas changer sur un refus: got=%q want=%q", s.Adresse, original)
}
if s.ProposedAddress != "" {
t.Errorf("proposed_address doit être vidé même en cas de refus: got=%q", s.ProposedAddress)
}
if s.AddressProposalStatus != "rejected" {
t.Errorf("address_proposal_status: got=%q want=rejected", s.AddressProposalStatus)
}
}
func TestRespondToAddressProposal_FailsWhenNoProposalPending(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_none")
productID := newTestProduct(t, "RespondAddrNone", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
if err := testDB.RespondToAddressProposal(cmdID, username, true); err == nil {
t.Fatal("attendu une erreur : aucune proposition en attente")
}
}
// La proposition est liée au client propriétaire de la commande : un autre
// client ne doit pas pouvoir y répondre à sa place.
func TestRespondToAddressProposal_WrongClientCannotRespond(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "respond_addr_owner")
intruder := newTestClient(t, "respond_addr_intruder")
productID := newTestProduct(t, "RespondAddrIntruder", 10)
cmdID := newTestCommandWithItem(t, owner, "assigned", "", productID, 1, 10)
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, intruder, true); err == nil {
t.Fatal("un client tiers ne doit pas pouvoir répondre à la proposition d'un autre client")
}
s := getCommandAddressState(t, cmdID)
if s.AddressProposalStatus != "pending" {
t.Errorf("la proposition doit rester en attente après une tentative d'un intrus: got=%q want=pending", s.AddressProposalStatus)
}
}
// Rejeu (double-tap) : une fois traitée, la même proposition ne doit pas
// pouvoir être acceptée/refusée une seconde fois.
func TestRespondToAddressProposal_DoubleRespondFails(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "respond_addr_double")
productID := newTestProduct(t, "RespondAddrDouble", 10)
cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil {
t.Fatalf("ProposeAddressChange: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil {
t.Fatalf("1ère réponse: %v", err)
}
if err := testDB.RespondToAddressProposal(cmdID, username, false); err == nil {
t.Fatal("une 2e réponse sur une proposition déjà traitée doit échouer")
}
// La 2e tentative (rejet) ne doit pas être appliquée par-dessus la 1ère (acceptation).
if got := getCommandAddressState(t, cmdID).AddressProposalStatus; got != "accepted" {
t.Errorf("le statut doit rester celui de la 1ère réponse: got=%q want=accepted", got)
}
}
@@ -0,0 +1,227 @@
package tests
import (
"fmt"
"strconv"
"testing"
)
// Adresses réalistes de Nantes (44000) utilisées pour vérifier que l'adresse
// de livraison survit intacte à la création puis à toutes les voies de
// récupération d'une commande (client, admin, livreur, historique).
var nantesAddresses = []string{
"12 Rue Crébillon, 44000 Nantes",
"3 Place Royale, 44000 Nantes",
"5 Cours des 50 Otages, 44000 Nantes",
"8 Rue de Verdun, 44000 Nantes",
}
// newTestCommandWithAddress crée directement une commande avec une adresse et
// un statut contrôlés (en contournant le checkout), pour tester isolément la
// récupération de l'adresse par les différentes fonctions de listing.
func newTestCommandWithAddress(t *testing.T, username, status, address, livreurAssign string, productID int, quantite, prix float64) int {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, adresse, livreur_assign, total_prix, created_at, updated_at)
VALUES (?, ?, ?, NULLIF(?, ''), ?, NOW(), NOW()) RETURNING id`,
username, status, address, livreurAssign, prix,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande test avec adresse: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item test: %v", err)
}
return cmdID
}
// Le checkout réel (panier -> CreateCommandWithAddress) doit stocker l'adresse
// telle quelle, et GetCommandByID doit la restituer à l'identique.
func TestCreateCommandWithAddress_RoundTripsRealNantesAddress(t *testing.T) {
for _, address := range nantesAddresses {
t.Run(address, func(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_checkout")
productID := newTestProduct(t, "AddrCheckout", 10)
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
cmd, err := testDB.CreateCommandWithAddress(username, address)
if err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
command, err := testDB.GetCommandByID(cmd.ID)
if err != nil {
t.Fatalf("GetCommandByID: %v", err)
}
got, _ := command["adresse"].(string)
if got != address {
t.Errorf("adresse récupérée: got=%q want=%q", got, address)
}
if got == "" {
t.Error("l'adresse ne doit jamais être vide")
}
})
}
}
// Une adresse vide ou uniquement composée d'espaces doit être rejetée au
// checkout — pas de commande créée avec une adresse de livraison absente.
func TestCreateCommandWithAddress_RejectsEmptyOrBlankAddress(t *testing.T) {
for _, address := range []string{"", " ", "\t\n"} {
cleanupStockTestData(t)
username := newTestClient(t, "addr_blank")
productID := newTestProduct(t, "AddrBlank", 10)
if _, err := testDB.AddToBasket(username, productID, 1); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, address); err == nil {
t.Errorf("adresse %q aurait dû être rejetée", address)
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&count)
if count != 0 {
t.Errorf("aucune commande ne doit être créée avec une adresse %q: got=%d", address, count)
}
}
}
// GetAllCommands (vue admin) doit toujours renvoyer l'adresse de chaque
// commande, quel que soit son statut.
func TestGetAllCommands_AlwaysIncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_admin_list")
productID := newTestProduct(t, "AddrAdminList", 10)
want := map[int]string{}
for i, address := range nantesAddresses {
status := []string{"pending", "assigned", "en_route", "livre"}[i%4]
cmdID := newTestCommandWithAddress(t, username, status, address, "", productID, 1, 10)
want[cmdID] = address
}
commands, err := testDB.GetAllCommands("", username)
if err != nil {
t.Fatalf("GetAllCommands: %v", err)
}
if len(commands) != len(want) {
t.Fatalf("nombre de commandes: got=%d want=%d", len(commands), len(want))
}
for _, c := range commands {
id, _ := c["id"].(int)
address, _ := c["adresse"].(string)
if address == "" {
t.Errorf("commande %d: adresse vide", id)
}
if want[id] != address {
t.Errorf("commande %d: adresse=%q want=%q", id, address, want[id])
}
}
}
// GetDeliveryPersonCommands (vue livreur) doit inclure l'adresse de chaque
// commande qui lui est assignée.
func TestGetDeliveryPersonCommands_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_livreur_client")
productID := newTestProduct(t, "AddrLivreur", 10)
livreurUsername := testUserPrefix + "addr_livreur"
address := nantesAddresses[0]
cmdID := newTestCommandWithAddress(t, username, "assigned", address, livreurUsername, productID, 1, 10)
commands, err := testDB.GetDeliveryPersonCommands(livreurUsername, "")
if err != nil {
t.Fatalf("GetDeliveryPersonCommands: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes assignées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
// Le type Go concret de la colonne "id" issue d'un Raw(...).Scan(&[]map[string]any)
// n'est pas garanti (int64 selon le driver) — même précaution que le code
// de production (ex: handlers/deleviry.go, fmt.Sprintf + strconv.Atoi).
id, _ := strconv.Atoi(fmt.Sprintf("%v", commands[0]["id"]))
if id != cmdID {
t.Errorf("id de commande inattendu: got=%d want=%d", id, cmdID)
}
}
// GetCancelledCommands doit inclure l'adresse même pour une commande annulée.
func TestGetCancelledCommands_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_cancelled")
productID := newTestProduct(t, "AddrCancelled", 10)
address := nantesAddresses[1]
newTestCommandWithAddress(t, username, "cancelled", address, "", productID, 1, 10)
commands, err := testDB.GetCancelledCommands(username, 10)
if err != nil {
t.Fatalf("GetCancelledCommands: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes annulées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
// GetCompletedCommandsByUsername (historique client) doit inclure l'adresse
// des commandes terminées (approved).
func TestGetCompletedCommandsByUsername_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_history")
productID := newTestProduct(t, "AddrHistory", 10)
address := nantesAddresses[2]
newTestCommandWithAddress(t, username, "approved", address, "", productID, 1, 10)
commands, err := testDB.GetCompletedCommandsByUsername(username)
if err != nil {
t.Fatalf("GetCompletedCommandsByUsername: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes terminées: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
// GetAllCommandsOldestFirst (vue cabine/admin triée) doit également inclure
// l'adresse de chaque commande.
func TestGetAllCommandsOldestFirst_IncludesAddress(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addr_oldest_first")
productID := newTestProduct(t, "AddrOldestFirst", 10)
address := nantesAddresses[3]
newTestCommandWithAddress(t, username, "pending", address, "", productID, 1, 10)
commands, err := testDB.GetAllCommandsOldestFirst("", username)
if err != nil {
t.Fatalf("GetAllCommandsOldestFirst: %v", err)
}
if len(commands) != 1 {
t.Fatalf("nombre de commandes: got=%d want=1", len(commands))
}
got, _ := commands[0]["adresse"].(string)
if got != address {
t.Errorf("adresse: got=%q want=%q", got, address)
}
}
@@ -0,0 +1,124 @@
package tests
import (
"bytes"
"encoding/json"
"gestion/handlers"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
// Ce fichier teste UpdateCommandStatusAdmin (annulation par admin/cabine).
// Ce chemin utilisait auparavant deux appels séparés (lecture du statut, puis
// restauration du stock hors transaction) — un double-tap ou appel concurrent
// pouvait alors rembourser le stock deux fois. Il délègue maintenant à
// db.CancelCommandByAdminAtomic, qui verrouille la commande (FOR UPDATE) et
// fait remboursement + changement de statut dans une seule transaction,
// comme CancelCommandAtomic (client) et CancelDeliveryByLivreurAtomic (livreur).
func adminCancelContext(commandID int) (*gin.Context, *httptest.ResponseRecorder) {
body, _ := json.Marshal(map[string]string{"status": "cancelled"})
req := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/orders/x/status", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("role", "admin")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
return c, rec
}
func TestUpdateCommandStatusAdmin_RefundsStockOnCancel(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_single")
productID := newTestProduct(t, "AdminCancelSingle", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
c, rec := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après annulation admin (5 initial + 3 remboursés): got=%.2f want=8", got)
}
}
// Statut déjà terminal (livré) : la commande peut toujours être basculée en
// "cancelled" par l'admin (correction), mais le stock ne doit pas être
// remboursé une seconde fois puisqu'il a déjà quitté l'entrepôt.
func TestUpdateCommandStatusAdmin_DoesNotRefundAlreadyDeliveredOrder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_livre")
productID := newTestProduct(t, "AdminCancelLivre", 5)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 3, 30)
c, rec := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got)
}
}
// Double-tap / retry réseau sur le bouton "annuler" côté admin : régression
// du bug corrigé (double remboursement). Barrière pour maximiser le
// recouvrement réel entre goroutines.
func TestUpdateCommandStatusAdmin_ConcurrentCancelDoesNotDoubleRefundStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_concurrent")
productID := newTestProduct(t, "AdminCancelConcurrent", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
n := 10
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
c, _ := adminCancelContext(cmdID)
handlers.UpdateCommandStatusAdmin(c)
}()
}
close(start)
wg.Wait()
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après %d annulations admin concurrentes de la même commande (5 initial + 3 remboursés une seule fois attendu): got=%.2f", n, got)
}
}
// Reproduction déterministe de l'ancienne fenêtre de course : deux appels
// annulent la même commande l'un juste après l'autre (simulation d'un
// double-tap sans dépendre du timing du scheduler). Avec CancelCommandByAdmin
// Atomic, le second appel voit la commande déjà 'cancelled' sous verrou et ne
// rembourse pas une seconde fois.
func TestUpdateCommandStatusAdmin_SequentialDoubleCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "admincancel_sequential")
productID := newTestProduct(t, "AdminCancelSequential", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
t.Fatalf("1er appel: %v", err)
}
if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil {
t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err)
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après double annulation admin: got=%.2f want=8 (un seul remboursement)", got)
}
}
@@ -0,0 +1,222 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
// newTestLivreur crée un utilisateur role=livreur réel (AssignDeliveryPerson
// vérifie son existence/rôle en base, pas juste le contexte gin) et
// programme son nettoyage.
func newTestLivreur(t *testing.T, name string) string {
t.Helper()
username := testUserPrefix + name
if err := testDB.GDB.Exec(
`INSERT INTO users (username, password, role) VALUES (?, 'x', 'livreur') ON CONFLICT (username) DO NOTHING`,
username,
).Error; err != nil {
t.Fatalf("création livreur test %q: %v", username, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM users WHERE username = ?`, username)
})
return username
}
func getAllCommandsContext(role string, query url.Values) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands?"+query.Encode(), nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"gac_admin")
c.Set("role", role)
return c, rec
}
// ── GetAllCommands ────────────────────────────────────────────────────────
func TestGetAllCommands_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := getAllCommandsContext("livreur", url.Values{})
handlers.GetAllCommands(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestGetAllCommands_FiltersByStatusAndUsername(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "gac_filter")
productID := newTestProduct(t, "GACFilter", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
otherUser := newTestClient(t, "gac_filter_other")
newTestCommandWithItem(t, otherUser, "cancelled", "", productID, 1, 10)
q := url.Values{}
q.Set("status", "pending")
q.Set("username", username)
c, rec := getAllCommandsContext("admin", q)
handlers.GetAllCommands(c)
if rec.Code != http.StatusOK {
t.Fatalf("requête filtrée doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(fmt.Sprintf(`"id":%d`, cmdID))) {
t.Errorf("la commande filtrée doit apparaître dans le résultat: body=%s", rec.Body.String())
}
}
func TestGetAllCommands_AllSentinelReturnsEverything(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "gac_all")
productID := newTestProduct(t, "GACAll", 10)
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands/all/all", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"gac_admin")
c.Set("role", "admin")
c.Params = gin.Params{{Key: "status", Value: "all"}, {Key: "username", Value: "all"}}
handlers.GetAllCommands(c)
if rec.Code != http.StatusOK {
t.Fatalf("sentinel 'all' doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── AssignDeliveryPerson ──────────────────────────────────────────────────
func assignContext(role string, body []byte, commandID int, livreurUsername string) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/assign", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", testUserPrefix+"assign_staff")
c.Set("role", role)
params := gin.Params{{Key: "command_id", Value: fmt.Sprintf("%d", commandID)}}
if livreurUsername != "" {
params = append(params, gin.Param{Key: "username", Value: livreurUsername})
}
c.Params = params
return c, rec
}
func TestAssignDeliveryPerson_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := assignContext("client", nil, 1, "someone")
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestAssignDeliveryPerson_SupportsCommandIDParam(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "assign_cmdid")
livreur := newTestLivreur(t, "assign_cmdid_livreur")
productID := newTestProduct(t, "AssignCmdID", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
c, rec := assignContext("admin", nil, cmdID, livreur)
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusOK {
t.Fatalf("assignation via :command_id doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestAssignDeliveryPerson_SupportsIDParamFallback(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "assign_id")
livreur := newTestLivreur(t, "assign_id_livreur")
productID := newTestProduct(t, "AssignID", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
req := httptest.NewRequest(http.MethodPost, "/api/v1/cabine/assign", bytes.NewReader([]byte(fmt.Sprintf(`{"livreur_username":%q}`, livreur))))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
c.Set("username", testUserPrefix+"assign_staff")
c.Set("role", "cabine")
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
handlers.AssignDeliveryPerson(c)
if rec.Code != http.StatusOK {
t.Fatalf("assignation via :id (fallback cabine) doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
}
// ── GetCommandItemsWithDetails ────────────────────────────────────────────
func itemsDetailedContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/items", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
if setUsername {
c.Set("username", username)
}
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
return c, rec
}
func TestGetCommandItemsWithDetails_UnauthenticatedReturns401(t *testing.T) {
cleanupStockTestData(t)
c, rec := itemsDetailedContext("", "client", 1, false)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
}
}
func TestGetCommandItemsWithDetails_IDORBlockedForOtherClient(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "items_owner")
intruder := newTestClient(t, "items_intruder")
productID := newTestProduct(t, "ItemsIDOR", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := itemsDetailedContext(intruder, "client", cmdID, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas accéder aux items: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetCommandItemsWithDetails_OwnerCanAccess(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "items_owner_ok")
productID := newTestProduct(t, "ItemsOwnerOk", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 2, 20)
c, rec := itemsDetailedContext(owner, "client", cmdID, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusOK {
t.Fatalf("le propriétaire doit accéder à ses items: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := itemsDetailedContext(testUserPrefix+"items_admin", "admin", 99999999, true)
handlers.GetCommandItemsWithDetails(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code)
}
}
+188
View File
@@ -0,0 +1,188 @@
package tests
import (
"bytes"
"encoding/json"
"fmt"
"math"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
// UpdateDeliveryStatus (handlers/deleviry.go) refuse de valider une livraison
// (statut "livre") si le livreur se trouve à plus de 350m de la destination
// (contrôle anti-fraude — seuil relevé de 100m à 350m à la demande explicite,
// pour tolérer l'imprécision GPS réelle en zone urbaine/immeuble).
const earthRadiusMeters = 6371000.0
// destinationPointNorthOf renvoie un point situé à "meters" au nord de
// (lat, lon) — même longitude, donc distance ≈ purement le delta de latitude
// (formule identique à utils.CalculateDistance pour ce cas particulier).
func destinationPointNorthOf(lat, lon, meters float64) (float64, float64) {
latOffsetRad := meters / earthRadiusMeters
latOffsetDeg := latOffsetRad * (180 / math.Pi)
return lat + latOffsetDeg, lon
}
func setCommandDestination(t *testing.T, commandID int, lat, lon float64) {
t.Helper()
if err := testDB.GDB.Exec(
`UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
lat, lon, commandID,
).Error; err != nil {
t.Fatalf("setCommandDestination: %v", err)
}
}
// deliveryStatusContextJSON est la variante de deliveryStatusContext (voir
// penalty_test.go) qui accepte un corps JSON arbitraire — nécessaire ici pour
// pouvoir passer latitude/longitude, que l'helper existant ne supporte pas.
func deliveryStatusContextJSON(username string, commandID int, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/livreur/deliveries/%d/status", commandID), bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
c.Set("database", testDB)
c.Set("username", username)
c.Set("role", "livreur")
return c, rec
}
const nantesLat, nantesLon = 47.2184, -1.5536
func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_within")
client := newTestClient(t, "gps_client_within")
productID := newTestProduct(t, "GPSWithin", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200) // 200m < 350m
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "livre" {
t.Errorf("statut après validation à 200m: got=%s want=livre", got)
}
}
func TestUpdateDeliveryStatus_GPS_BeyondThresholdRejectsValidation(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_beyond")
client := newTestClient(t, "gps_client_beyond")
productID := newTestProduct(t, "GPSBeyond", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 400) // 400m > 350m
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("le statut ne doit pas passer à 'livre' au-delà de 350m: got=%s want=en_route", got)
}
}
// Preuve directe du changement demandé : une distance de 150m, qui aurait
// échoué sous l'ancien seuil de 100m, doit maintenant réussir sous 350m.
func TestUpdateDeliveryStatus_GPS_150Meters_PassesUnderNewThreshold(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_150m")
client := newTestClient(t, "gps_client_150m")
productID := newTestProduct(t, "GPS150m", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 150)
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("150m doit être accepté sous le nouveau seuil de 350m: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "livre" {
t.Errorf("statut après validation à 150m: got=%s want=livre", got)
}
}
func TestUpdateDeliveryStatus_GPS_MissingCoordinatesRejected(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_missing_coords")
client := newTestClient(t, "gps_client_missing_coords")
productID := newTestProduct(t, "GPSMissingCoords", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
body, _ := json.Marshal(map[string]any{"status": "livre"}) // latitude/longitude absents (zéro)
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status HTTP sans coordonnées: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("le statut ne doit pas changer sans coordonnées GPS: got=%s want=en_route", got)
}
}
// Si la commande n'a pas de coordonnées de destination enregistrées (adresse
// non géocodée), la validation GPS est ignorée plutôt que de bloquer le
// livreur indéfiniment.
func TestUpdateDeliveryStatus_GPS_MissingDestinationCoordinatesSkipsValidation(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_no_dest")
client := newTestClient(t, "gps_client_no_dest")
productID := newTestProduct(t, "GPSNoDest", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
// Pas d'appel à setCommandDestination : dest_latitude/dest_longitude restent à 0/NULL.
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": 48.8566, "longitude": 2.3522}) // Paris, sans rapport
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("sans destination enregistrée, la validation GPS doit être ignorée: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "livre" {
t.Errorf("statut: got=%s want=livre", got)
}
}
func TestUpdateDeliveryStatus_RejectsWhenNotAssignedToThisLivreur(t *testing.T) {
cleanupStockTestData(t)
assignedLivreur := newTestClient(t, "gps_assigned_livreur")
intruder := newTestClient(t, "gps_intruder_livreur")
client := newTestClient(t, "gps_client_wrong_livreur")
productID := newTestProduct(t, "GPSWrongLivreur", 10)
cmdID := newTestCommandWithItem(t, client, "en_route", assignedLivreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": nantesLat, "longitude": nantesLon})
c, rec := deliveryStatusContextJSON(intruder, cmdID, body)
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusForbidden {
t.Fatalf("un livreur non assigné doit être rejeté: got=%d want=%d body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
}
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("le statut ne doit pas changer: got=%s want=en_route", got)
}
}
+261
View File
@@ -0,0 +1,261 @@
package tests
import (
"encoding/json"
"gestion/db"
"gestion/models"
"testing"
"time"
)
// newTestProductWithCategory crée un produit de test avec une catégorie
// personnalisée (contrairement à newTestProduct qui pose toujours "test") —
// nécessaire ici pour distinguer les catégories dans le routage par livreur.
func newTestProductWithCategory(t *testing.T, name, category string, stock float64) int {
t.Helper()
fullName := testProductPrefix + name
var id int
if err := testDB.GDB.Raw(
`INSERT INTO products (name, category, description, stock) VALUES (?, ?, '', ?) RETURNING id`,
fullName, category, stock,
).Scan(&id).Error; err != nil {
t.Fatalf("création produit test %q: %v", fullName, err)
}
if err := testDB.GDB.Exec(
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`,
id,
).Error; err != nil {
t.Fatalf("création prix produit test %q: %v", fullName, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id)
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id)
})
return id
}
// setLivreurStatus place directement en Redis le statut d'un livreur, comme
// le ferait l'app livreur en production (clé "delivery:status:{username}").
func setLivreurStatus(t *testing.T, username, status string) {
t.Helper()
data, err := json.Marshal(models.DeliveryPersonStatus{
Username: username,
Status: status,
LastUpdate: time.Now(),
})
if err != nil {
t.Fatalf("marshal DeliveryPersonStatus: %v", err)
}
key := "delivery:status:" + username
if err := db.Redis.Set(db.RedisCtx, key, data, 0).Err(); err != nil {
t.Fatalf("setLivreurStatus: %v", err)
}
t.Cleanup(func() {
db.Redis.Del(db.RedisCtx, key)
})
}
func setDeliveryModeSettings(t *testing.T, mode models.DeliveryModeConfig) {
t.Helper()
data, err := json.Marshal(mode)
if err != nil {
t.Fatalf("marshal delivery_mode: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO app_settings (key, value) VALUES ('delivery_mode', ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
string(data),
).Error; err != nil {
t.Fatalf("setDeliveryModeSettings: %v", err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'delivery_mode'`)
})
}
func containsUsername(list []string, username string) bool {
for _, u := range list {
if u == username {
return true
}
}
return false
}
// ── GetCommandCategories ─────────────────────────────────────────────────────
func TestGetCommandCategories_ReturnsDistinctProductCategories(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_categories")
productA := newTestProductWithCategory(t, "CatA", "cat_a", 10)
productB := newTestProductWithCategory(t, "CatB", "cat_b", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10)
testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`,
cmdID, productB,
)
categories, err := testDB.GetCommandCategories(cmdID)
if err != nil {
t.Fatalf("GetCommandCategories: %v", err)
}
if len(categories) != 2 || !containsUsername(categories, "cat_a") || !containsUsername(categories, "cat_b") {
t.Errorf("catégories: got=%v want=[cat_a cat_b]", categories)
}
}
// ── GetEligibleDeliverymenForCommand ─────────────────────────────────────────
func TestGetEligibleDeliverymenForCommand_SingleModeReturnsAllActive(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_single")
productID := newTestProductWithCategory(t, "Single", "cat_a", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
livreurA := testUserPrefix + "delivmode_single_a"
livreurB := testUserPrefix + "delivmode_single_b"
setLivreurStatus(t, livreurA, "available")
setLivreurStatus(t, livreurB, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) {
t.Errorf("mode single doit renvoyer tous les livreurs actifs: got=%v", eligible)
}
}
func TestGetEligibleDeliverymenForCommand_CategoryBasedFiltersToMatchingRoute(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_filter")
productID := newTestProductWithCategory(t, "Filter", "cat_a", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
livreurA := testUserPrefix + "delivmode_filter_a"
livreurB := testUserPrefix + "delivmode_filter_b"
setLivreurStatus(t, livreurA, "available")
setLivreurStatus(t, livreurB, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{
Mode: "category_based",
CategoryRoutes: []models.CategoryRoute{
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}},
{DeliverymanUsername: livreurB, Categories: []string{"cat_b"}},
},
})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) {
t.Errorf("livreurA (cat_a) doit être éligible: got=%v", eligible)
}
if containsUsername(eligible, livreurB) {
t.Errorf("livreurB (cat_b, non commandée) ne doit pas être éligible: got=%v", eligible)
}
}
// Cas limite documenté explicitement dans le modèle métier : une commande
// mixte (catégories relevant de livreurs différents) doit renvoyer l'UNION
// des livreurs éligibles, pas une intersection (aucun livreur unique ne gère
// forcément toutes les catégories à la fois).
func TestGetEligibleDeliverymenForCommand_MixedCategoryCommand_ReturnsUnion(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_mixed")
productA := newTestProductWithCategory(t, "MixedA", "cat_a", 10)
productB := newTestProductWithCategory(t, "MixedB", "cat_b", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10)
testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`,
cmdID, productB,
)
livreurA := testUserPrefix + "delivmode_mixed_a"
livreurB := testUserPrefix + "delivmode_mixed_b"
setLivreurStatus(t, livreurA, "available")
setLivreurStatus(t, livreurB, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{
Mode: "category_based",
CategoryRoutes: []models.CategoryRoute{
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}},
{DeliverymanUsername: livreurB, Categories: []string{"cat_b"}},
},
})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) {
t.Errorf("commande mixte cat_a+cat_b doit renvoyer l'union des deux livreurs: got=%v", eligible)
}
}
func TestGetEligibleDeliverymenForCommand_NoRouteMatchesFallsBackToAllActive(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_nomatch")
productID := newTestProductWithCategory(t, "NoMatch", "cat_c", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
livreurA := testUserPrefix + "delivmode_nomatch_a"
setLivreurStatus(t, livreurA, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{
Mode: "category_based",
CategoryRoutes: []models.CategoryRoute{
{DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, // ne couvre pas cat_c
},
})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) {
t.Errorf("aucune route ne couvre cat_c -> repli sur tous les livreurs actifs: got=%v", eligible)
}
}
func TestGetEligibleDeliverymenForCommand_EmptyCategoryRoutesFallsBackToAllActive(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_emptyroutes")
productID := newTestProductWithCategory(t, "EmptyRoutes", "cat_a", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
livreurA := testUserPrefix + "delivmode_emptyroutes_a"
setLivreurStatus(t, livreurA, "available")
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "category_based", CategoryRoutes: []models.CategoryRoute{}})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if !containsUsername(eligible, livreurA) {
t.Errorf("category_based sans route configurée -> repli sur tous les livreurs actifs: got=%v", eligible)
}
}
func TestGetEligibleDeliverymenForCommand_OfflineLivreurNeverEligible(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delivmode_offline")
productID := newTestProductWithCategory(t, "Offline", "cat_a", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
onlineLivreur := testUserPrefix + "delivmode_offline_online"
offlineLivreur := testUserPrefix + "delivmode_offline_offline"
setLivreurStatus(t, onlineLivreur, "available")
setLivreurStatus(t, offlineLivreur, "offline")
setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"})
eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID)
if err != nil {
t.Fatalf("GetEligibleDeliverymenForCommand: %v", err)
}
if containsUsername(eligible, offlineLivreur) {
t.Errorf("un livreur offline ne doit jamais être éligible: got=%v", eligible)
}
if !containsUsername(eligible, onlineLivreur) {
t.Errorf("le livreur en ligne doit être éligible: got=%v", eligible)
}
}
+194
View File
@@ -0,0 +1,194 @@
package tests
import (
"bytes"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"gestion/db"
"gestion/handlers"
"gestion/services"
"github.com/gin-gonic/gin"
)
// db.Redis n'est initialisé qu'après TestMain (db.InitRedis) — un var
// package-level serait construit trop tôt, d'où cette init paresseuse.
var testGeoService *services.GeoService
// ensureTestGeoService initialise paresseusement le GeoService partagé
// (db.Redis n'existe qu'après TestMain) — réutilisé par les autres fichiers
// de tests qui ont besoin de "geoService" dans le contexte gin.
func ensureTestGeoService() *services.GeoService {
if testGeoService == nil {
testGeoService = services.NewGeoService(db.Redis, db.RedisCtx)
}
return testGeoService
}
func etaContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/eta", bytes.NewReader(nil))
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("geoService", ensureTestGeoService())
if setUsername {
c.Set("username", username)
}
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}}
return c, rec
}
func TestGetOrderETA_UnauthenticatedReturns401(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext("", "client", 1, false)
handlers.GetOrderETA(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code)
}
}
func TestGetOrderETA_InvalidCommandIDReturns400(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext(testUserPrefix+"eta_badid", "client", 0, true)
c.Params = gin.Params{{Key: "id", Value: "not-a-number"}}
handlers.GetOrderETA(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code)
}
}
func TestGetOrderETA_UnknownCommandReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := etaContext(testUserPrefix+"eta_404", "client", 99999999, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusNotFound {
t.Errorf("commande inconnue doit retourner 404: got=%d", rec.Code)
}
}
func TestGetOrderETA_ClientAccessingAnotherClientCommandReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_owner")
intruder := newTestClient(t, "eta_intruder")
productID := newTestProduct(t, "EtaOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur", productID, 1, 10)
c, rec := etaContext(intruder, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un autre client ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_LivreurNotAssignedReturns403(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_lv_owner")
productID := newTestProduct(t, "EtaLvOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_real_livreur", productID, 1, 10)
c, rec := etaContext("eta_other_livreur", "livreur", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusForbidden {
t.Errorf("un livreur non assigné ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_AdminBypassesOwnershipChecks(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_admin_owner")
productID := newTestProduct(t, "EtaAdminOwner", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := etaContext(testUserPrefix+"eta_admin", "admin", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Errorf("admin doit pouvoir accéder à l'ETA de n'importe quelle commande: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestGetOrderETA_DeliveredStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_delivered")
productID := newTestProduct(t, "EtaDelivered", 10)
cmdID := newTestCommandWithItem(t, owner, "livre", "eta_livreur_d", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut livre doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false pour une commande livrée: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_PendingStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_pending")
productID := newTestProduct(t, "EtaPending", 10)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut pending doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false pour une commande pending: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_ArrivedStatusReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_arrived")
productID := newTestProduct(t, "EtaArrived", 10)
cmdID := newTestCommandWithItem(t, owner, "arrived", "eta_livreur_a", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("statut arrived doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false quand le livreur est arrivé: body=%s", rec.Body.String())
}
}
func TestGetOrderETA_NoLivreurAssignedReturnsEtaUnavailable(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_nolivreur")
productID := newTestProduct(t, "EtaNoLivreur", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("sans livreur assigné doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false sans livreur assigné: body=%s", rec.Body.String())
}
}
// Sans coordonnées de destination et sans cache Redis préalable, le
// handler doit tomber sur returnStaleOrUnavailable plutôt que planter.
func TestGetOrderETA_MissingDestinationCoordsFallsBackGracefully(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "eta_nodest")
productID := newTestProduct(t, "EtaNoDest", 10)
cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur_nodest", productID, 1, 10)
c, rec := etaContext(owner, "client", cmdID, true)
handlers.GetOrderETA(c)
if rec.Code != http.StatusOK {
t.Fatalf("sans coordonnées destination doit quand même retourner 200: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) {
t.Errorf("eta_available doit être false sans coordonnées destination: body=%s", rec.Body.String())
}
}
@@ -0,0 +1,79 @@
package tests
import (
"math"
"testing"
"gestion/services"
)
// Deux points à Nantes séparés d'environ 1.1km à vol d'oiseau (Haversine).
var (
nantesCentre = services.Coordinates{Latitude: 47.2184, Longitude: -1.5536}
nantesProche = services.Coordinates{Latitude: 47.2280, Longitude: -1.5536}
)
func TestCalculateDistance_HaversineCorrectness(t *testing.T) {
dist := services.CalculateDistance(nantesCentre, nantesProche)
// ~0.0096 rad de latitude ≈ 1.067km — tolérance large pour la formule.
if dist < 0.9 || dist > 1.3 {
t.Errorf("distance Haversine hors plage attendue: got=%.3fkm want≈1.07km", dist)
}
}
func TestCalculateDistance_SamePointIsZero(t *testing.T) {
dist := services.CalculateDistance(nantesCentre, nantesCentre)
if dist != 0 {
t.Errorf("distance entre un point et lui-même doit être 0: got=%.4f", dist)
}
}
func TestCalculateETA_UnderPointOneKmReturnsMinETA(t *testing.T) {
eta := services.CalculateETA(0.05)
if eta != services.MinETA {
t.Errorf("distance < 0.1km doit retourner MinETA: got=%d want=%d", eta, services.MinETA)
}
}
func TestCalculateETA_AppliesTwentyPercentTrafficMargin(t *testing.T) {
// 25km à 25km/h = 60min ; +20% marge = 72min (dans les bornes [MinETA, MaxETA]).
eta := services.CalculateETA(25)
want := 72
if eta != want {
t.Errorf("ETA avec marge trafic 20%%: got=%d want=%d", eta, want)
}
}
func TestCalculateETA_ClampsToMaxETA(t *testing.T) {
eta := services.CalculateETA(1000)
if eta != services.MaxETA {
t.Errorf("très longue distance doit être plafonnée à MaxETA: got=%d want=%d", eta, services.MaxETA)
}
}
func TestCalculateETA_ClampsToMinETA(t *testing.T) {
// distance faible mais non nulle, donnant un temps de trajet < MinETA
// après calcul (pas la branche <0.1km, une distance différente).
eta := services.CalculateETA(0.5)
if eta < services.MinETA {
t.Errorf("ETA ne doit jamais être inférieur à MinETA: got=%d want>=%d", eta, services.MinETA)
}
}
// Sans clé API TomTom configurée (cas de cet environnement de test),
// CalculateETAWithTomTom doit retomber sur le calcul local identique à
// CalculateDistance+CalculateETA.
func TestCalculateETAWithTomTom_FallsBackToLocalCalcWithoutAPIKey(t *testing.T) {
etaMinutes, distanceKm, err := services.CalculateETAWithTomTom(nantesCentre, nantesProche)
if err != nil {
t.Fatalf("fallback local ne doit pas retourner d'erreur: %v", err)
}
wantDistance := services.CalculateDistance(nantesCentre, nantesProche)
wantETA := services.CalculateETA(wantDistance)
if math.Abs(distanceKm-wantDistance) > 0.0001 {
t.Errorf("distance fallback: got=%.4f want=%.4f", distanceKm, wantDistance)
}
if etaMinutes != wantETA {
t.Errorf("eta fallback: got=%d want=%d", etaMinutes, wantETA)
}
}
@@ -0,0 +1,59 @@
package tests
import "testing"
func TestGetLastDeliveryCoords_NoPreviousDeliveryReturnsError(t *testing.T) {
cleanupStockTestData(t)
_, _, err := testDB.GetLastDeliveryCoords(testUserPrefix + "coords_nolivraison")
if err == nil {
t.Fatal("sans livraison précédente, une erreur est attendue")
}
}
func TestGetLastDeliveryCoords_FallsBackToDBWhenNoCache(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "coords_db")
livreur := testUserPrefix + "coords_db_livreur"
productID := newTestProduct(t, "CoordsDB", 10)
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 47.2184, dest_longitude = -1.5536, updated_at = NOW() WHERE id = ?`, cmdID)
lat, lon, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("GetLastDeliveryCoords: %v", err)
}
if lat != 47.2184 || lon != -1.5536 {
t.Errorf("coordonnées depuis la DB: got=(%.4f,%.4f) want=(47.2184,-1.5536)", lat, lon)
}
}
// Une fois les coordonnées lues depuis la DB, elles sont mises en cache
// Redis — un second appel doit renvoyer la valeur cachée même si la DB
// change entretemps (TTL non expiré).
func TestGetLastDeliveryCoords_CachesResultInRedis(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "coords_cache")
livreur := testUserPrefix + "coords_cache_livreur"
productID := newTestProduct(t, "CoordsCache", 10)
cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10)
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 48.8566, dest_longitude = 2.3522, updated_at = NOW() WHERE id = ?`, cmdID)
lat1, _, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("premier appel: %v", err)
}
if lat1 != 48.8566 {
t.Fatalf("premier appel doit lire la DB: got lat=%.4f want=48.8566", lat1)
}
// Change la valeur en DB — un cache-hit doit ignorer ce changement.
testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 0, dest_longitude = 0 WHERE id = ?`, cmdID)
lat2, lon2, err := testDB.GetLastDeliveryCoords(livreur)
if err != nil {
t.Fatalf("second appel (cache attendu): %v", err)
}
if lat2 != 48.8566 || lon2 != 2.3522 {
t.Errorf("second appel doit retourner la valeur cachée, pas la DB modifiée: got=(%.4f,%.4f) want=(48.8566,2.3522)", lat2, lon2)
}
}
@@ -0,0 +1,179 @@
package tests
import (
"fmt"
"gestion/db"
"strings"
"testing"
"time"
)
const notificationsScheduledKey = "notifications:scheduled"
// ScheduleETANotifications programme un rappel 5min et 3min avant l'arrivée
// estimée — mais seulement si l'ETA le justifie (voir bornes ci-dessous).
func TestScheduleETANotifications_SchedulesBothForLongETA(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_long")
productID := newTestProduct(t, "EtaNotifLong", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
before := time.Now()
if err := testDB.ScheduleETANotifications(cmdID, 10); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
score5, err := getScheduledScore(t, cmdID, "5min")
if err != nil {
t.Fatalf("notification 5min absente: %v", err)
}
score3, err := getScheduledScore(t, cmdID, "3min")
if err != nil {
t.Fatalf("notification 3min absente: %v", err)
}
wantAt5 := before.Add(5 * time.Minute).Unix() // arrivée dans 10min - 5min = dans 5min
wantAt3 := before.Add(7 * time.Minute).Unix() // arrivée dans 10min - 3min = dans 7min
if diff := abs64(score5 - wantAt5); diff > 2 {
t.Errorf("score notification 5min: got=%d want≈%d (écart %ds)", score5, wantAt5, diff)
}
if diff := abs64(score3 - wantAt3); diff > 2 {
t.Errorf("score notification 3min: got=%d want≈%d (écart %ds)", score3, wantAt3, diff)
}
if score3 <= score5 {
t.Errorf("la notification 3min doit être programmée après la 5min (plus proche de l'arrivée): score5=%d score3=%d", score5, score3)
}
}
// À la limite exacte (ETA = 5 min), un rappel "5 minutes avant l'arrivée"
// se déclencherait immédiatement (redondant) — il n'est donc volontairement
// pas programmé (condition stricte ">5", pas ">=5"). Seul le rappel 3min
// reste pertinent.
func TestScheduleETANotifications_ExactlyFiveMinutes_SkipsFiveMinReminder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_five")
productID := newTestProduct(t, "EtaNotifFive", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 5); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification 5min ne doit être programmée quand ETA=5min exactement")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
t.Errorf("la notification 3min doit être programmée quand ETA=5min: %v", err)
}
}
// À ETA = 3 min, même le rappel "3 minutes avant" serait immédiat : aucune
// notification ne doit être programmée.
func TestScheduleETANotifications_ExactlyThreeMinutes_SchedulesNothing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_three")
productID := newTestProduct(t, "EtaNotifThree", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 3); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification 5min ne doit être programmée pour ETA=3min")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
t.Error("aucune notification 3min ne doit être programmée pour ETA=3min (serait immédiate)")
}
}
// Une ETA très courte (MinETA=3min, plancher de tout le système) ne doit
// jamais programmer de notification de rappel — cohérent avec le cas ci-dessus.
func TestScheduleETANotifications_VeryShortETASchedulesNothing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_short")
productID := newTestProduct(t, "EtaNotifShort", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.ScheduleETANotifications(cmdID, 1); err != nil {
t.Fatalf("ScheduleETANotifications: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err == nil {
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
}
if _, err := getScheduledScore(t, cmdID, "3min"); err == nil {
t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute")
}
}
// SetCommandETA (appelée par UpdateDeliveryStatus au passage en "en_route")
// déclenche automatiquement ScheduleETANotifications — vérifie l'intégration
// complète, pas seulement la fonction isolée.
func TestSetCommandETA_TriggersScheduledNotifications(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_integration")
productID := newTestProduct(t, "EtaNotifIntegration", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "5min"); err != nil {
t.Errorf("SetCommandETA doit programmer une notification 5min: %v", err)
}
if _, err := getScheduledScore(t, cmdID, "3min"); err != nil {
t.Errorf("SetCommandETA doit programmer une notification 3min: %v", err)
}
}
// Le message envoyé au client doit contenir une indication de temps lisible,
// pas juste le statut brut.
func TestSendETANotification_MessageContainsReadableTime(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_notif_message")
productID := newTestProduct(t, "EtaNotifMessage", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10)
channel := fmt.Sprintf("notifications:command:%d", cmdID)
pubsub := db.Redis.Subscribe(db.RedisCtx, channel)
defer pubsub.Close()
// Consommer le message de confirmation d'abonnement avant de publier.
if _, err := pubsub.Receive(db.RedisCtx); err != nil {
t.Fatalf("abonnement pubsub: %v", err)
}
testDB.SendETANotification(cmdID, "5min")
select {
case msg := <-pubsub.Channel():
if msg.Payload == "" {
t.Fatal("message de notification vide")
}
if !strings.Contains(msg.Payload, "5min") {
t.Errorf("le message doit indiquer le temps restant (%q): %q", "5min", msg.Payload)
}
t.Logf("message reçu: %q", msg.Payload)
case <-time.After(3 * time.Second):
t.Fatal("aucun message reçu sur le canal de notification dans le délai imparti")
}
}
func abs64(n int64) int64 {
if n < 0 {
return -n
}
return n
}
// getScheduledScore lit le score (timestamp Unix) d'une notification
// programmée pour "{commandID}:{suffix}" dans le sorted set Redis.
func getScheduledScore(t *testing.T, commandID int, suffix string) (int64, error) {
t.Helper()
member := fmt.Sprintf("%d:%s", commandID, suffix)
score, err := db.Redis.ZScore(db.RedisCtx, notificationsScheduledKey, member).Result()
if err != nil {
return 0, err
}
return int64(score), nil
}
+208
View File
@@ -0,0 +1,208 @@
package tests
import (
"encoding/json"
"gestion/handlers"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
)
// Les clients ont signalé ne jamais voir le temps de livraison (notifications,
// app mobile, site web). Ces tests reproduisent le chemin réel : une commande
// est assignée automatiquement (le worker de queue appelle
// SetCommandETAWithDetails, PAS SetCommandETA), puis un client consulte le
// suivi de sa commande — exactement ce que fait l'app mobile / le site web.
func etaTestContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/x/tracking", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", username)
c.Set("role", "client")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
return c, rec
}
// SetCommandETAWithDetails est le chemin utilisé par le worker
// d'auto-assignation/réoptimisation de queue (db/redis_queue_optimization.go,
// db/redis_queue_assignment.go) — de loin le plus emprunté en production.
// Il doit remplir "eta_minutes", pas seulement "total_eta_minutes", car
// c'est le champ que l'app mobile et le site web lisent (confirmé dans
// mobile/src/screens/client/OrderTrackingScreen.tsx et api.ts des deux
// frontends).
func TestSetCommandETAWithDetails_WritesEtaMinutesField(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_details_field")
productID := newTestProduct(t, "EtaDetailsField", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 22, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
etaData, err := testDB.GetCommandETA(cmdID)
if err != nil {
t.Fatalf("GetCommandETA: %v", err)
}
got, ok := etaData["eta_minutes"]
if !ok || got == "" {
t.Errorf(`champ "eta_minutes" absent après SetCommandETAWithDetails (contenu: %v) — `+
`c'est le champ lu par le mobile et le site web, d'où l'absence de temps affiché`, etaData)
} else if got != "22" {
t.Errorf(`"eta_minutes" = %q, want "22"`, got)
}
}
// Reproduction bout-en-bout du symptôme signalé : après une assignation
// automatique (SetCommandETAWithDetails), le client consulte le suivi de sa
// commande (GetCommandTracking, l'endpoint utilisé par l'app mobile et le
// site web) — la réponse doit exposer eta.eta_minutes.
func TestGetCommandTracking_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_tracking_client")
productID := newTestProduct(t, "EtaTrackingClient", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA2", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 17, 2); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
c, rec := etaTestContext(username, cmdID)
handlers.GetCommandTracking(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"`
ETA map[string]any `json:"eta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String())
}
etaMinutesRaw, ok := resp.ETA["eta_minutes"]
if !ok {
t.Fatalf(`la réponse de /tracking n'expose pas "eta.eta_minutes" (contenu eta: %v) — `+
`reproduit exactement le bug signalé par les clients`, resp.ETA)
}
etaMinutesStr, _ := etaMinutesRaw.(string)
if got, _ := strconv.Atoi(etaMinutesStr); got != 17 {
t.Errorf("eta.eta_minutes: got=%v want=17", etaMinutesRaw)
}
}
// Même reproduction via GetCommandStatus (autre endpoint de suivi, utilisé
// par l'app mobile pour le statut temps réel).
func TestGetCommandStatus_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_status_client")
productID := newTestProduct(t, "EtaStatusClient", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA3", productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 9, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
c, rec := etaTestContext(username, cmdID)
handlers.GetCommandStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
ETA map[string]any `json:"eta"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v", err)
}
if _, ok := resp.ETA["eta_minutes"]; !ok {
t.Fatalf(`GetCommandStatus n'expose pas "eta.eta_minutes" (contenu: %v)`, resp.ETA)
}
}
// SetCommandETA (chemin utilisé par UpdateDeliveryStatus côté livreur) doit
// lui aussi rester lisible par les lecteurs qui attendent "total_eta_minutes"
// (handlers/deleviry.go, validation_deleviry.go, geoloca.go).
func TestSetCommandETA_AlsoWritesTotalEtaMinutesField(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_simple_field")
productID := newTestProduct(t, "EtaSimpleField", 10)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA4", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 14); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
etaData, err := testDB.GetCommandETA(cmdID)
if err != nil {
t.Fatalf("GetCommandETA: %v", err)
}
if got, ok := etaData["total_eta_minutes"]; !ok || got != "14" {
t.Errorf(`"total_eta_minutes" = %q (présent=%v), want "14"`, got, ok)
}
if got, ok := etaData["eta_minutes"]; !ok || got != "14" {
t.Errorf(`"eta_minutes" = %q (présent=%v), want "14"`, got, ok)
}
}
// GetDeliverymanLocationForCommand (vue admin/cabine) lisait l'ETA via
// Redis.Get sur une clé qui est en réalité un hash (HSet) — l'erreur
// WRONGTYPE était silencieusement ignorée et etaMinutes restait toujours à 0.
func TestGetDeliverymanLocationForCommand_ExposesEtaMinutes(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_admin_view_client")
productID := newTestProduct(t, "EtaAdminView", 10)
livreurUsername := testUserPrefix + "eta_admin_view_livreur"
cmdID := newTestCommandWithItem(t, username, "en_route", livreurUsername, productID, 1, 10)
if err := testDB.SetCommandETAWithDetails(cmdID, 12, 1); err != nil {
t.Fatalf("SetCommandETAWithDetails: %v", err)
}
// Position GPS du livreur, requise par le handler avant de lire l'ETA.
if err := testDB.UpdateDeliveryPersonLocation(livreurUsername, 47.2148, -1.5584); err != nil {
t.Fatalf("UpdateDeliveryPersonLocation: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", "admin_test")
c.Set("role", "admin")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(cmdID)}}
handlers.GetDeliverymanLocationForCommand(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Data struct {
ETA struct {
Minutes float64 `json:"minutes"`
HasETA bool `json:"has_eta"`
} `json:"eta"`
} `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 !resp.Data.ETA.HasETA {
t.Errorf("has_eta devrait être true, ETA pourtant définie via SetCommandETAWithDetails")
}
if resp.Data.ETA.Minutes != 12 {
t.Errorf("data.eta.minutes: got=%.0f want=12", resp.Data.ETA.Minutes)
}
}
+137
View File
@@ -0,0 +1,137 @@
package tests
import (
"encoding/json"
"gestion/handlers"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// newTestDeliveredOrder crée une commande livrée/approuvée assignée à un
// livreur avec une date de mise à jour contrôlée (GetMyDeliveryStats groupe
// par updated_at, pas created_at).
func newTestDeliveredOrder(t *testing.T, livreurUsername, clientUsername, status string, productID int, quantite, prix, referralUsed float64, updatedAt time.Time) {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, referral_used, created_at, updated_at)
VALUES (?, ?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
clientUsername, status, livreurUsername, prix, referralUsed, updatedAt, updatedAt,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande livrée test: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'delivered')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item commande livrée test: %v", err)
}
}
func livreurStatsContext(username string) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", 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
}
type deliveryStatsResponse struct {
Success bool `json:"success"`
TodayCount int `json:"today_count"`
TodayRevenue float64 `json:"today_revenue"`
}
// today_count/today_revenue ne doivent compter que les livraisons du jour
// courant (livre/approved), nettes du crédit de parrainage — pas les jours
// précédents, même récents (voir by_day/by_week qui eux les agrègent).
func TestGetMyDeliveryStats_TodayCountAndRevenue(t *testing.T) {
cleanupStockTestData(t)
livreurUsername := newTestClient(t, "stats_livreur_today")
clientUsername := newTestClient(t, "stats_livreur_today_client")
productID := newTestProduct(t, "StatsLivreurToday", 100)
now := time.Now()
yesterday := now.AddDate(0, 0, -1)
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 2, 40, 10, now) // net 30, aujourd'hui
newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 1, 25, 0, yesterday) // hier, ne doit pas compter dans "today"
c, rec := livreurStatsContext(livreurUsername)
handlers.GetMyDeliveryStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp deliveryStatsResponse
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("success=false, body=%s", rec.Body.String())
}
if resp.TodayCount != 1 {
t.Errorf("today_count: got=%d want=1 (la commande d'hier ne doit pas compter)", resp.TodayCount)
}
if resp.TodayRevenue != 30 {
t.Errorf("today_revenue: got=%.2f want=30 (40 - 10 de parrainage)", resp.TodayRevenue)
}
}
// Aucune livraison aujourd'hui : today_count/today_revenue doivent être 0,
// pas une absence de champ (voir le bug initial où le seul indicateur de
// "livraisons du jour" était l'absence de ligne dans by_day).
func TestGetMyDeliveryStats_TodayCountZeroWhenNoDeliveryToday(t *testing.T) {
cleanupStockTestData(t)
livreurUsername := newTestClient(t, "stats_livreur_none")
c, rec := livreurStatsContext(livreurUsername)
handlers.GetMyDeliveryStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp deliveryStatsResponse
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.TodayCount != 0 {
t.Errorf("today_count: got=%d want=0", resp.TodayCount)
}
if resp.TodayRevenue != 0 {
t.Errorf("today_revenue: got=%.2f want=0", resp.TodayRevenue)
}
}
// Seules les commandes assignées à CE livreur doivent être comptées.
func TestGetMyDeliveryStats_OnlyCountsOwnDeliveries(t *testing.T) {
cleanupStockTestData(t)
livreurA := newTestClient(t, "stats_livreur_a")
livreurB := newTestClient(t, "stats_livreur_b")
client := newTestClient(t, "stats_livreur_shared_client")
productID := newTestProduct(t, "StatsLivreurIsolation", 100)
now := time.Now()
newTestDeliveredOrder(t, livreurA, client, "approved", productID, 1, 20, 0, now)
newTestDeliveredOrder(t, livreurB, client, "approved", productID, 1, 999, 0, now)
c, rec := livreurStatsContext(livreurA)
handlers.GetMyDeliveryStats(c)
var resp deliveryStatsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("décodage réponse: %v", err)
}
if resp.TodayCount != 1 || resp.TodayRevenue != 20 {
t.Errorf("stats livreur A ne doivent refléter que ses propres livraisons: got count=%d revenue=%.2f want count=1 revenue=20", resp.TodayCount, resp.TodayRevenue)
}
}
+140
View File
@@ -0,0 +1,140 @@
// Package tests regroupe les tests de bout en bout de la gestion de stock,
// écrits contre l'API publique des paquets db/ et handlers/ (aucun accès à
// leurs symboles non exportés) — voir docker-compose.yml pour la base de
// test locale nécessaire pour les exécuter (`go test ./tests/...`).
package tests
import (
"fmt"
"gestion/db"
"os"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
)
// testDB est l'instance partagée par tous les tests de ce paquet. Toutes les
// données créées utilisent un préfixe dédié (testUserPrefix / testProductPrefix)
// et sont nettoyées avant et après chaque test, ce qui rend la suite sans
// danger même si elle tourne contre une base partagée.
var testDB *db.Database
const (
testUserPrefix = "stocktest_"
testProductPrefix = "TESTSTOCK_"
)
// testPhoneCounter garantit un numéro de téléphone unique par client de test
// (colonne UNIQUE sur clients.telephone).
var testPhoneCounter int64
func nextTestPhone() string {
n := atomic.AddInt64(&testPhoneCounter, 1)
return fmt.Sprintf("+3361%09d", n)
}
func TestMain(m *testing.M) {
gin.SetMode(gin.TestMode)
testDB = db.InitDB()
db.InitRedis()
os.Exit(m.Run())
}
// cleanupStockTestData supprime toutes les données créées par les tests de
// gestion de stock (identifiées par leur préfixe), dans le bon ordre pour
// respecter les contraintes de clé étrangère.
func cleanupStockTestData(t *testing.T) {
t.Helper()
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username LIKE ?)`, testUserPrefix+"%")
testDB.GDB.Exec(`DELETE FROM commandes WHERE username LIKE ?`, testUserPrefix+"%")
testDB.GDB.Exec(`DELETE FROM baskets WHERE username LIKE ?`, testUserPrefix+"%")
testDB.GDB.Exec(`DELETE FROM clients WHERE username LIKE ?`, testUserPrefix+"%")
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name LIKE ?)`, testProductPrefix+"%")
testDB.GDB.Exec(`DELETE FROM products WHERE name LIKE ?`, testProductPrefix+"%")
}
// newTestProduct crée un produit de test avec un stock initial donné et un
// prix actif pour quantity=1, et programme son nettoyage en fin de test.
func newTestProduct(t *testing.T, name string, stock float64) int {
t.Helper()
fullName := testProductPrefix + name
var id int
if err := testDB.GDB.Raw(
`INSERT INTO products (name, category, description, stock) VALUES (?, 'test', '', ?) RETURNING id`,
fullName, stock,
).Scan(&id).Error; err != nil {
t.Fatalf("création produit test %q: %v", fullName, err)
}
if err := testDB.GDB.Exec(
`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`,
id,
).Error; err != nil {
t.Fatalf("création prix produit test %q: %v", fullName, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id)
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id)
})
return id
}
// productStock relit le stock courant d'un produit directement en base.
func productStock(t *testing.T, productID int) float64 {
t.Helper()
var stock float64
if err := testDB.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&stock).Error; err != nil {
t.Fatalf("lecture stock produit %d: %v", productID, err)
}
return stock
}
// newTestClient crée un client de test et programme son nettoyage en fin de test.
func newTestClient(t *testing.T, name string) string {
t.Helper()
username := testUserPrefix + name
if err := testDB.GDB.Exec(
`INSERT INTO clients (username, password, nom, prenom, telephone) VALUES (?, 'x', 'T', 'C', ?)
ON CONFLICT (username) DO NOTHING`,
username, nextTestPhone(),
).Error; err != nil {
t.Fatalf("création client test %q: %v", username, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username = ?)`, username)
testDB.GDB.Exec(`DELETE FROM commandes WHERE username = ?`, username)
testDB.GDB.Exec(`DELETE FROM clients WHERE username = ?`, username)
})
return username
}
// newTestCommandWithItem crée directement une commande avec un item (en
// contournant le checkout), pour tester isolément les chemins d'annulation
// et de remboursement de stock. Retourne l'ID de la commande créée.
func newTestCommandWithItem(t *testing.T, username, status, livreurAssign string, productID int, quantite float64, prix float64) int {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, created_at, updated_at)
VALUES (?, ?, NULLIF(?, ''), 'Adresse test', ?, NOW(), NOW()) RETURNING id`,
username, status, livreurAssign, prix,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande test: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item test: %v", err)
}
return cmdID
}
func commandStatus(t *testing.T, commandID int) string {
t.Helper()
var status string
testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&status)
return status
}
@@ -0,0 +1,276 @@
package tests
import (
"bytes"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"gestion/handlers"
"gestion/services"
"github.com/gin-gonic/gin"
)
// Un MinIO local (docker) sert de backend S3 réel pour ces tests — pas de
// mock : mêmes chemins de code que RustFS en production (SDK AWS v2,
// UsePathStyle=true).
var testS3Service *services.S3Service
func getTestS3Service(t *testing.T) *services.S3Service {
t.Helper()
if testS3Service == nil {
s3, err := services.NewS3Service(
"us-east-1", "test-products", "http://localhost:9500",
services.S3Credentials{S3KeyId: "testadmin", S3AccessKey: "testpassword123"},
)
if err != nil {
t.Fatalf("NewS3Service: %v", err)
}
testS3Service = s3
}
return testS3Service
}
// tiny1x1PNG est un PNG valide minimal (1x1 pixel transparent), pour que
// la détection MIME réelle (mimetype.DetectReader) le reconnaisse comme
// image/png.
var tiny1x1PNG = []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}
func uploadMediaRequest(fileType, fileName string, fileContent []byte) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
w.WriteField("type", fileType)
part, _ := w.CreateFormFile("file", fileName)
part.Write(fileContent)
w.Close()
return body, w.FormDataContentType()
}
func mediaContext(t *testing.T, role string, body *bytes.Buffer, contentType string, productID int, mediaID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products/media", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("s3Service", getTestS3Service(t))
c.Set("username", testUserPrefix+"media_admin")
c.Set("role", role)
params := gin.Params{}
if productID != 0 {
params = append(params, gin.Param{Key: "id", Value: fmt.Sprintf("%d", productID)})
}
if mediaID != 0 {
params = append(params, gin.Param{Key: "media_id", Value: fmt.Sprintf("%d", mediaID)})
}
c.Params = params
return c, rec
}
// ── UploadMedia ──────────────────────────────────────────────────────────
func TestUploadMedia_RejectsNonAdmin(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaRoleCheck", 5)
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
c, rec := mediaContext(t, "cabine", body, ct, productID, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role cabine doit être refusé pour UploadMedia: got=%d want=403", rec.Code)
}
}
func TestUploadMedia_RejectsInvalidType(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaBadType", 5)
body, ct := uploadMediaRequest("audio", "photo.png", tiny1x1PNG)
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("type 'audio' invalide doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestUploadMedia_RejectsMimeMismatch(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaMimeMismatch", 5)
// Contenu texte brut mais annoncé comme "image" — le sniff MIME réel doit le détecter.
body, ct := uploadMediaRequest("image", "fake.png", []byte("ceci n'est pas une image, juste du texte brut"))
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("contenu non-image envoyé comme type=image doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestUploadMedia_UnknownProductReturns404(t *testing.T) {
cleanupStockTestData(t)
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
c, rec := mediaContext(t, "admin", body, ct, 99999999, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusNotFound {
t.Errorf("produit inconnu doit retourner 404: got=%d", rec.Code)
}
}
func TestUploadMedia_SuccessUploadsToS3AndCreatesMediaRow(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaUploadOk", 5)
body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
c, rec := mediaContext(t, "admin", body, ct, productID, 0)
handlers.UploadMedia(c)
if rec.Code != http.StatusCreated {
t.Fatalf("upload valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
media, err := testDB.GetMediaByProductID(productID)
if err != nil || len(media) != 1 {
t.Fatalf("un média doit être créé en base: err=%v count=%d", err, len(media))
}
// Vérifie que le fichier existe réellement sur MinIO (pas juste en DB).
rc, _, err := getTestS3Service(t).GetFile(t.Context(), media[0].Key)
if err != nil {
t.Fatalf("le fichier doit être récupérable depuis S3: %v", err)
}
rc.Close()
t.Cleanup(func() {
getTestS3Service(t).DeleteFile(media[0].Key)
})
}
// ── DeleteMedia ──────────────────────────────────────────────────────────
func TestDeleteMedia_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
c, rec := mediaContext(t, "livreur", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, 1)
handlers.DeleteMedia(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code)
}
}
func TestDeleteMedia_UnknownMediaReturns404(t *testing.T) {
cleanupStockTestData(t)
c, rec := mediaContext(t, "admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, 99999999)
handlers.DeleteMedia(c)
if rec.Code != http.StatusNotFound {
t.Errorf("média inconnu doit retourner 404: got=%d", rec.Code)
}
}
func TestDeleteMedia_SuccessDeletesFromS3AndDB(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "MediaDeleteOk", 5)
uploadBody, uploadCT := uploadMediaRequest("image", "photo.png", tiny1x1PNG)
uc, urec := mediaContext(t, "admin", uploadBody, uploadCT, productID, 0)
handlers.UploadMedia(uc)
if urec.Code != http.StatusCreated {
t.Fatalf("upload préalable doit réussir: got=%d body=%s", urec.Code, urec.Body.String())
}
media, err := testDB.GetMediaByProductID(productID)
if err != nil || len(media) != 1 {
t.Fatalf("un média doit exister avant suppression: err=%v count=%d", err, len(media))
}
mediaID := media[0].ID
key := media[0].Key
c, rec := mediaContext(t, "admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, mediaID)
handlers.DeleteMedia(c)
if rec.Code != http.StatusOK {
t.Fatalf("suppression doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
remaining, _ := testDB.GetMediaByProductID(productID)
if len(remaining) != 0 {
t.Errorf("le média doit être supprimé en base: got=%d lignes restantes", len(remaining))
}
if _, _, err := getTestS3Service(t).GetFile(t.Context(), key); err == nil {
t.Error("le fichier doit être supprimé de S3, mais il est toujours récupérable")
}
}
// ── CreateProduct avec média réel (intégration multipart) ───────────────
//
// Contrairement à UploadMedia (qui écrit sur S3/RustFS), le chemin média de
// CreateProduct écrit sur le DISQUE LOCAL du serveur (c.SaveUploadedFile
// vers "uploads/<type>s/...") et ne renseigne jamais media.Key — donc ce
// fichier n'est pas récupérable via S3Service.GetFile ni via ServeMedia
// (qui lit par clé S3). C'est une incohérence d'architecture entre les deux
// chemins de code, pas juste une différence de test : à signaler séparément
// pour décision (stockage local perdu au redéploiement si le conteneur n'a
// pas de volume persistant sur "uploads/", et média invisible pour
// DeleteMedia's nettoyage S3). Ce test vérifie donc le comportement réel
// actuel (fichier sur disque local), pas un comportement souhaité en S3.
func TestCreateProduct_WithMediaSavesFileToLocalDisk(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatWithMedia")
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
w.WriteField("name", testProductPrefix+"CPMedia")
w.WriteField("category", cat)
w.WriteField("description", "d")
w.WriteField("stock", "5")
w.WriteField("prices[0][quantity]", "1")
w.WriteField("prices[0][price]", "5")
part, _ := w.CreateFormFile("media", "photo.png")
part.Write(tiny1x1PNG)
w.Close()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body)
req.Header.Set("Content-Type", w.FormDataContentType())
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("s3Service", getTestS3Service(t))
c.Set("username", testUserPrefix+"media_admin")
c.Set("role", "admin")
handlers.CreateProduct(c)
if rec.Code != http.StatusCreated {
t.Fatalf("création produit avec média doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var productID int
testDB.GDB.Raw(`SELECT id FROM products WHERE name = ?`, testProductPrefix+"CPMedia").Scan(&productID)
if productID == 0 {
t.Fatalf("le produit doit être créé")
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID)
testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID)
})
media, err := testDB.GetMediaByProductID(productID)
if err != nil || len(media) != 1 {
t.Fatalf("un média doit être associé au produit: err=%v count=%d", err, len(media))
}
if media[0].Key != "" {
t.Errorf("comportement actuel: media.Key doit être vide (pas de clé S3) pour le chemin CreateProduct: got=%q", media[0].Key)
}
localPath := strings.TrimPrefix(media[0].URL, "/")
if _, err := os.Stat(localPath); err != nil {
t.Errorf("le fichier doit exister sur le disque local à %q: %v", localPath, err)
}
t.Cleanup(func() {
os.Remove(localPath)
})
}
+160
View File
@@ -0,0 +1,160 @@
package tests
import (
"sync"
"testing"
)
// SetClientParrainAndCredit lie un parrain à un client ET crédite le parrain
// dans une seule transaction — la doc métier avertit explicitement que sans
// cette atomicité, un crédit peut être appliqué sans lien enregistré, ou
// l'inverse (lien enregistré sans jamais créditer le parrain).
func TestSetClientParrainAndCredit_LinksAndCreditsAtomically(t *testing.T) {
cleanupStockTestData(t)
client := newTestClient(t, "parrain_ok_client")
parrain := newTestClient(t, "parrain_ok_parrain")
setClientReferralBalance(t, parrain, 5)
if err := testDB.SetClientParrainAndCredit(client, parrain, 10); err != nil {
t.Fatalf("SetClientParrainAndCredit: %v", err)
}
got, err := testDB.GetClientParrain(client)
if err != nil {
t.Fatalf("GetClientParrain: %v", err)
}
if got != parrain {
t.Errorf("parrain enregistré: got=%q want=%q", got, parrain)
}
if bal := referralBalance(t, parrain); bal != 15 {
t.Errorf("solde du parrain après crédit (5 + 10): got=%.2f want=15", bal)
}
}
// Un client qui a déjà un parrain ne doit jamais pouvoir en changer via cette
// fonction (contrainte "parrain déjà défini") — et surtout, le nouveau
// parrain proposé ne doit recevoir aucun crédit si le lien est refusé.
func TestSetClientParrainAndCredit_RejectsIfClientAlreadyHasParrain(t *testing.T) {
cleanupStockTestData(t)
client := newTestClient(t, "parrain_already_client")
firstParrain := newTestClient(t, "parrain_already_first")
secondParrain := newTestClient(t, "parrain_already_second")
if err := testDB.SetClientParrainAndCredit(client, firstParrain, 10); err != nil {
t.Fatalf("1er lien: %v", err)
}
if err := testDB.SetClientParrainAndCredit(client, secondParrain, 10); err == nil {
t.Fatal("attendu un rejet : le client a déjà un parrain")
}
if got, _ := testDB.GetClientParrain(client); got != firstParrain {
t.Errorf("le parrain enregistré ne doit pas changer: got=%q want=%q", got, firstParrain)
}
if bal := referralBalance(t, secondParrain); bal != 0 {
t.Errorf("le second parrain (lien refusé) ne doit recevoir aucun crédit: got=%.2f want=0", bal)
}
if bal := referralBalance(t, firstParrain); bal != 10 {
t.Errorf("le premier parrain garde son crédit initial, pas de second crédit: got=%.2f want=10", bal)
}
}
// Scénario exact mis en garde par la doc métier : si le parrain indiqué
// n'existe pas, le crédit échoue — et le lien parrain (première moitié de la
// transaction) doit être annulé avec, pas laissé enregistré tout seul.
func TestSetClientParrainAndCredit_RejectsUnknownParrain_RollsBackLink(t *testing.T) {
cleanupStockTestData(t)
client := newTestClient(t, "parrain_unknown_client")
unknownParrain := testUserPrefix + "does_not_exist_parrain"
if err := testDB.SetClientParrainAndCredit(client, unknownParrain, 10); err == nil {
t.Fatal("attendu une erreur : le parrain n'existe pas")
}
got, err := testDB.GetClientParrain(client)
if err != nil {
t.Fatalf("GetClientParrain: %v", err)
}
if got != "" {
t.Errorf("le lien parrain ne doit PAS être enregistré si le crédit échoue (rollback complet): got=%q want=\"\"", got)
}
}
// Quand le parrainage est désactivé (ReferralEnabled=false côté handler), le
// montant crédité vaut 0 — la liaison doit tout de même réussir sans tenter
// de créditer personne.
func TestSetClientParrainAndCredit_ZeroCreditLinksWithoutCrediting(t *testing.T) {
cleanupStockTestData(t)
client := newTestClient(t, "parrain_zero_client")
parrain := newTestClient(t, "parrain_zero_parrain")
if err := testDB.SetClientParrainAndCredit(client, parrain, 0); err != nil {
t.Fatalf("SetClientParrainAndCredit avec montant nul: %v", err)
}
if got, _ := testDB.GetClientParrain(client); got != parrain {
t.Errorf("le lien doit être enregistré même sans crédit: got=%q want=%q", got, parrain)
}
if bal := referralBalance(t, parrain); bal != 0 {
t.Errorf("aucun crédit ne doit être appliqué avec un montant nul: got=%.2f want=0", bal)
}
}
func TestSetClientParrainAndCredit_RejectsUnknownClient(t *testing.T) {
cleanupStockTestData(t)
parrain := newTestClient(t, "parrain_unknown_client_target")
unknownClient := testUserPrefix + "does_not_exist_client"
if err := testDB.SetClientParrainAndCredit(unknownClient, parrain, 10); err == nil {
t.Fatal("attendu une erreur : le client cible n'existe pas")
}
if bal := referralBalance(t, parrain); bal != 0 {
t.Errorf("le parrain ne doit pas être crédité si le client cible est introuvable: got=%.2f want=0", bal)
}
}
// Deux tentatives concurrentes de parrainage sur le MÊME client (deux
// parrains différents) ne doivent en laisser passer qu'une seule — la
// condition "parrain IS NULL OR parrain = ''" de l'UPDATE sérialise
// naturellement les deux tentatives au niveau de la ligne.
func TestSetClientParrainAndCredit_ConcurrentSetOnSameClientOnlyOneSucceeds(t *testing.T) {
cleanupStockTestData(t)
client := newTestClient(t, "parrain_concurrent_client")
parrainA := newTestClient(t, "parrain_concurrent_a")
parrainB := newTestClient(t, "parrain_concurrent_b")
var wg sync.WaitGroup
errs := make([]error, 2)
wg.Add(2)
go func() {
defer wg.Done()
errs[0] = testDB.SetClientParrainAndCredit(client, parrainA, 10)
}()
go func() {
defer wg.Done()
errs[1] = testDB.SetClientParrainAndCredit(client, parrainB, 10)
}()
wg.Wait()
successCount := 0
for _, err := range errs {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("une seule tentative concurrente de parrainage doit réussir: got=%d succès", successCount)
}
finalParrain, _ := testDB.GetClientParrain(client)
if finalParrain != parrainA && finalParrain != parrainB {
t.Fatalf("parrain final inattendu: %q", finalParrain)
}
balA := referralBalance(t, parrainA)
balB := referralBalance(t, parrainB)
if (balA == 10) == (balB == 10) {
t.Errorf("exactement un des deux parrains doit être crédité de 10, pas les deux ni aucun: balA=%.2f balB=%.2f", balA, balB)
}
}
+405
View File
@@ -0,0 +1,405 @@
package tests
import (
"bytes"
"gestion/handlers"
"net/http"
"net/http/httptest"
"strconv"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
func clientAmendeAndCount(t *testing.T, username string) (amende float64, count int) {
t.Helper()
var row struct {
Amende float64 `gorm:"column:amende"`
CancellationsCount int `gorm:"column:cancellations_count"`
}
if err := testDB.GDB.Raw(
`SELECT COALESCE(amende, 0) AS amende, COALESCE(cancellations_count, 0) AS cancellations_count FROM clients WHERE username = ?`,
username,
).Scan(&row).Error; err != nil {
t.Fatalf("lecture amende/cancellations_count: %v", err)
}
return row.Amende, row.CancellationsCount
}
// ── CalculateCancellationPenalty : barème par défaut ────────────────────────
// (0->20, 1->50, 2->100, 3 et plus->150 — voir DefaultSettings)
func TestCalculateCancellationPenalty_MatchesDefaultTiers(t *testing.T) {
cases := []struct {
cancellationsCount int
wantPenalty int
}{
{0, 20},
{1, 50},
{2, 100},
{3, 150},
{10, 150}, // palier "4e et plus", plafonné
}
for _, c := range cases {
cleanupStockTestData(t)
username := newTestClient(t, "penalty_tier")
testDB.GDB.Exec(`UPDATE clients SET cancellations_count = ? WHERE username = ?`, c.cancellationsCount, username)
got, err := testDB.CalculateCancellationPenalty(username)
if err != nil {
t.Fatalf("CalculateCancellationPenalty (count=%d): %v", c.cancellationsCount, err)
}
if got != c.wantPenalty {
t.Errorf("count=%d: penalty=%d want=%d", c.cancellationsCount, got, c.wantPenalty)
}
}
}
// ── ApplyCancellationPenalty ("client absent" livreur) : cumul + concurrence ─
func TestApplyCancellationPenalty_AccumulatesAcrossSequentialCalls(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "penalty_sequential")
wantPenalties := []int{20, 50, 100, 150}
wantCumulative := []float64{20, 70, 170, 320}
for i, want := range wantPenalties {
penalty, err := testDB.ApplyCancellationPenalty(username)
if err != nil {
t.Fatalf("appel %d: %v", i+1, err)
}
if penalty != want {
t.Errorf("appel %d: penalty=%d want=%d", i+1, penalty, want)
}
amende, count := clientAmendeAndCount(t, username)
if amende != wantCumulative[i] {
t.Errorf("appel %d: amende cumulée=%.2f want=%.2f", i+1, amende, wantCumulative[i])
}
if count != i+1 {
t.Errorf("appel %d: cancellations_count=%d want=%d", i+1, count, i+1)
}
}
}
// Plusieurs "client absent" quasi simultanés sur le même client (deux
// livreurs différents annulant chacun une commande de ce client au même
// moment) ne doivent pas se marcher dessus (verrou FOR UPDATE).
func TestApplyCancellationPenalty_ConcurrentCallsDoNotLoseUpdates(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "penalty_concurrent")
n := 4
var wg sync.WaitGroup
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, errs[idx] = testDB.ApplyCancellationPenalty(username)
}(i)
}
wg.Wait()
for i, err := range errs {
if err != nil {
t.Errorf("appel concurrent %d: erreur inattendue: %v", i, err)
}
}
amende, count := clientAmendeAndCount(t, username)
if count != n {
t.Errorf("cancellations_count après %d appels concurrents: got=%d want=%d", n, count, n)
}
if amende != 320 { // 20+50+100+150, un seul palier consommé par appel
t.Errorf("amende après %d appels concurrents: got=%.2f want=320.00 (pas de mise à jour perdue)", n, amende)
}
}
// ── CheckCommandETAExistsAndValid (bug corrigé : lisait la clé avec Get au lieu de HGetAll) ─
func TestCheckCommandETAExistsAndValid_FalseWhenNoETA(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_check_none")
productID := newTestProduct(t, "EtaCheckNone", 5)
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurCheckETA", productID, 1, 10)
if testDB.CheckCommandETAExistsAndValid(cmdID) {
t.Error("aucune ETA définie: attendu false")
}
}
func TestCheckCommandETAExistsAndValid_TrueWhenETASet(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "eta_check_valid")
productID := newTestProduct(t, "EtaCheckValid", 5)
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurCheckETA2", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 15); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
if !testDB.CheckCommandETAExistsAndValid(cmdID) {
t.Error("ETA valide définie: attendu true")
}
}
// ── CancelCommandAtomic : détection d'annulation tardive et pénalité ────────
func TestCancelCommandAtomic_NoPenaltyWithoutLivreur(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_no_livreur")
productID := newTestProduct(t, "CancelPenaltyNoLivreur", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err != nil {
t.Fatalf("CancelCommandAtomic: %v", err)
}
if penalty != 0 {
t.Errorf("aucune pénalité attendue sans livreur assigné: got=%d", penalty)
}
amende, count := clientAmendeAndCount(t, username)
if amende != 0 {
t.Errorf("amende doit rester à 0: got=%.2f", amende)
}
if count != 1 {
t.Errorf("cancellations_count doit tout de même être incrémenté: got=%d want=1", count)
}
}
func TestCancelCommandAtomic_LateCancel_EnRoute_RequiresForceConfirmation(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_enroute_noforce")
productID := newTestProduct(t, "CancelPenaltyEnrouteNoforce", 5)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurLate1", productID, 1, 10)
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err == nil || err.Error() != "confirmation requise" {
t.Fatalf(`attendu l'erreur "confirmation requise", got=%v`, err)
}
if got := commandStatus(t, cmdID); got != "en_route" {
t.Errorf("le statut ne doit pas changer sans confirmation: got=%s want=en_route", got)
}
if got := productStock(t, productID); got != 5 {
t.Errorf("le stock ne doit pas être remboursé sans confirmation: got=%.2f want=5", got)
}
amende, _ := clientAmendeAndCount(t, username)
if amende != 0 {
t.Errorf("aucune amende ne doit être appliquée sans confirmation: got=%.2f", amende)
}
}
func TestCancelCommandAtomic_LateCancel_Arrived_RequiresForceConfirmation(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_arrived_noforce")
productID := newTestProduct(t, "CancelPenaltyArrivedNoforce", 5)
cmdID := newTestCommandWithItem(t, username, "arrived", testUserPrefix+"livreurLate2", productID, 1, 10)
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err == nil || err.Error() != "confirmation requise" {
t.Fatalf(`attendu l'erreur "confirmation requise" pour status=arrived, got=%v`, err)
}
}
func TestCancelCommandAtomic_LateCancelWithForce_AppliesPenalty(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_enroute_force")
productID := newTestProduct(t, "CancelPenaltyEnrouteForce", 5)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurLate3", productID, 2, 20)
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
if err != nil {
t.Fatalf("CancelCommandAtomic avec force: %v", err)
}
if penalty != 20 { // 1ère annulation de ce client
t.Errorf("penalty: got=%d want=20", penalty)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut: got=%s want=cancelled", got)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après annulation confirmée (5 initial + 2 remboursés): got=%.2f want=7", got)
}
amende, count := clientAmendeAndCount(t, username)
if amende != 20 {
t.Errorf("amende: got=%.2f want=20", amende)
}
if count != 1 {
t.Errorf("cancellations_count: got=%d want=1", count)
}
}
// Un livreur assigné mais sans statut avancé (assigned) et sans ETA connue
// n'est pas considéré comme une annulation tardive : pas besoin de force.
func TestCancelCommandAtomic_AssignedWithoutETA_NoForceNeeded(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_assigned_noeta")
productID := newTestProduct(t, "CancelPenaltyAssignedNoeta", 5)
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurLate4", productID, 1, 10)
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err != nil {
t.Fatalf("CancelCommandAtomic: %v", err)
}
if penalty != 0 {
t.Errorf("aucune pénalité attendue (pas d'ETA, statut non avancé): got=%d", penalty)
}
}
// Un livreur assigné avec une ETA valide en cache doit être traité comme une
// annulation tardive, même si le statut est encore "assigned" (régression du
// bug CheckCommandETAExistsAndValid corrigé ci-dessus).
func TestCancelCommandAtomic_AssignedWithValidETA_RequiresForce(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_assigned_eta")
productID := newTestProduct(t, "CancelPenaltyAssignedEta", 5)
cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurLate5", productID, 1, 10)
if err := testDB.SetCommandETA(cmdID, 12); err != nil {
t.Fatalf("SetCommandETA: %v", err)
}
_, err := testDB.CancelCommandAtomic(cmdID, username, "test", false)
if err == nil || err.Error() != "confirmation requise" {
t.Fatalf(`attendu "confirmation requise" (ETA valide définie): got=%v`, err)
}
// Avec force=true, la pénalité doit maintenant s'appliquer.
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
if err != nil {
t.Fatalf("CancelCommandAtomic avec force: %v", err)
}
if penalty != 20 {
t.Errorf("penalty: got=%d want=20", penalty)
}
}
func TestCancelCommandAtomic_PenaltyProgressesAcrossMultipleLateCancellations(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_progression")
productID := newTestProduct(t, "CancelPenaltyProgression", 20)
wantPenalties := []int{20, 50, 100, 150}
for i, want := range wantPenalties {
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurProg", productID, 1, 10)
penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true)
if err != nil {
t.Fatalf("annulation %d: %v", i+1, err)
}
if penalty != want {
t.Errorf("annulation %d: penalty=%d want=%d", i+1, penalty, want)
}
}
amende, count := clientAmendeAndCount(t, username)
if count != 4 {
t.Errorf("cancellations_count: got=%d want=4", count)
}
if amende != 320 { // 20+50+100+150
t.Errorf("amende cumulée: got=%.2f want=320.00", amende)
}
}
func TestCancelCommandAtomic_NonCancellableStatusRejected(t *testing.T) {
for _, status := range []string{"livre", "approved", "cancelled", "disabled"} {
t.Run(status, func(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_penalty_terminal_"+status)
productID := newTestProduct(t, "CancelPenaltyTerminal"+status, 5)
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", true); err == nil {
t.Errorf("statut %q devrait être rejeté même avec force=true", status)
}
})
}
}
func TestCancelCommandAtomic_WrongOwnerRejected(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "cancel_penalty_owner")
intruder := newTestClient(t, "cancel_penalty_intruder")
productID := newTestProduct(t, "CancelPenaltyOwner", 5)
cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10)
if _, err := testDB.CancelCommandAtomic(cmdID, intruder, "test", false); err == nil {
t.Error("un client ne doit pas pouvoir annuler la commande d'un autre client")
}
}
// ── Flux "client absent" (livreur annule depuis 'arrived') via le handler ───
func deliveryStatusContext(livreurUsername string, commandID int, status, notes string) (*gin.Context, *httptest.ResponseRecorder) {
body := []byte(`{"status":"` + status + `","notes":"` + notes + `"}`)
req := httptest.NewRequest(http.MethodPut, "/x", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", livreurUsername)
c.Set("role", "livreur")
c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}}
return c, rec
}
// Quand le livreur marque le client absent (annulation depuis "arrived"),
// l'amende doit être appliquée au CLIENT, jamais au livreur — règle métier
// explicite (comprehension-metier).
func TestUpdateDeliveryStatus_ClientAbsent_AppliesPenaltyToClientNotLivreur(t *testing.T) {
cleanupStockTestData(t)
clientUsername := newTestClient(t, "penalty_absent_client")
livreurUsername := testUserPrefix + "penalty_absent_livreur"
productID := newTestProduct(t, "PenaltyAbsent", 5)
cmdID := newTestCommandWithItem(t, clientUsername, "arrived", livreurUsername, productID, 1, 10)
c, rec := deliveryStatusContext(livreurUsername, cmdID, "cancelled", "Client absent")
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
clientAmende, clientCount := clientAmendeAndCount(t, clientUsername)
if clientAmende != 20 {
t.Errorf("amende client après 'client absent': got=%.2f want=20", clientAmende)
}
if clientCount != 1 {
t.Errorf("cancellations_count client: got=%d want=1", clientCount)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut commande: got=%s want=cancelled", got)
}
if got := productStock(t, productID); got != 6 {
t.Errorf("stock après remboursement (5 initial + 1 remboursé): got=%.2f want=6", got)
}
}
// Annuler depuis un statut autre que "arrived"/"livre" (ex: "en_route") ne
// doit PAS déclencher la pénalité "client absent" — ce n'est pas le même
// motif d'annulation.
func TestUpdateDeliveryStatus_CancelFromEnRoute_DoesNotApplyClientAbsentPenalty(t *testing.T) {
cleanupStockTestData(t)
clientUsername := newTestClient(t, "penalty_enroute_client")
livreurUsername := testUserPrefix + "penalty_enroute_livreur"
productID := newTestProduct(t, "PenaltyEnrouteCancel", 5)
cmdID := newTestCommandWithItem(t, clientUsername, "en_route", livreurUsername, productID, 1, 10)
c, rec := deliveryStatusContext(livreurUsername, cmdID, "cancelled", "Problème livraison")
handlers.UpdateDeliveryStatus(c)
if rec.Code != http.StatusOK {
t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
}
clientAmende, _ := clientAmendeAndCount(t, clientUsername)
if clientAmende != 0 {
t.Errorf("aucune amende ne doit être appliquée depuis en_route: got=%.2f", clientAmende)
}
}
@@ -0,0 +1,256 @@
package tests
import (
"sync"
"testing"
"gestion/models"
)
// ── ApproveDeliveryAtomicByStaff : confirmation de réception par admin/cabine
// à la place du client. Contrairement au chemin client (ApproveDeliveryAtomic),
// aucune vérification de propriétaire n'est faite ici (le staff agit au nom
// du client) — mais la contrainte de statut ('livre' uniquement) est
// identique, et la double-approbation renvoie une VRAIE erreur (pas un no-op
// silencieux comme côté client).
func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "staff_approve_ok")
productID := newTestProduct(t, "StaffApproveOk", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
pts, _, clientOut, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test")
if err != nil {
t.Fatalf("ApproveDeliveryAtomicByStaff: %v", err)
}
if pts != 6 {
t.Errorf("points retournés: got=%d want=6", pts)
}
if clientOut != username {
t.Errorf("client retourné: got=%q want=%q", clientOut, username)
}
if got := commandStatus(t, cmdID); got != "approved" {
t.Errorf("statut après confirmation staff: got=%s want=approved", got)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points_extra après confirmation staff: got=%d want=6", got)
}
}
func TestApproveDeliveryAtomicByStaff_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) {
cleanupStockTestData(t)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
for _, status := range []string{"pending", "assigned", "en_route", "arrived"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "staff_reject_"+status)
productID := newTestProduct(t, "StaffReject"+status, 20)
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err == nil {
t.Fatalf("attendu un rejet pour une commande en statut %q", status)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 0 {
t.Errorf("aucun point ne doit être crédité (statut=%s): got=%d want=0", status, got)
}
if got := commandStatus(t, cmdID); got != status {
t.Errorf("le statut ne doit pas changer: got=%s want=%s", got, status)
}
})
}
}
// Contrairement à ApproveDeliveryAtomic (client), une seconde confirmation
// staff sur une commande déjà approuvée renvoie une VRAIE erreur, pas un
// no-op silencieux — divergence de comportement à documenter explicitement.
func TestApproveDeliveryAtomicByStaff_DoubleApprove_ReturnsErrorAndDoesNotDoublePoints(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "staff_double")
productID := newTestProduct(t, "StaffDouble", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err != nil {
t.Fatalf("1ère confirmation: %v", err)
}
if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err == nil {
t.Fatal("la 2e confirmation sur une commande déjà approuvée doit renvoyer une erreur (contrairement au chemin client)")
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points après double confirmation staff: got=%d want=6 (un seul crédit)", got)
}
}
func TestApproveDeliveryAtomicByStaff_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "staff_concurrent")
productID := newTestProduct(t, "StaffConcurrent", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
var wg sync.WaitGroup
n := 3
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, _, _, errs[idx] = testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test")
}(i)
}
wg.Wait()
successCount := 0
for _, err := range errs {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("une seule confirmation concurrente doit réussir: got=%d succès", successCount)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points après confirmations concurrentes: got=%d want=6 (un seul crédit)", got)
}
}
// Le staff n'est pas le client : aucune vérification de propriétaire n'est
// faite (comportement voulu, à la différence du chemin client).
func TestApproveDeliveryAtomicByStaff_NoOwnershipCheck_AnyStaffCanConfirmAnyClient(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "staff_no_owner_check")
productID := newTestProduct(t, "StaffNoOwnerCheck", 20)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
if _, _, clientOut, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "un_autre_membre_staff"); err != nil {
t.Fatalf("un membre du staff quelconque doit pouvoir confirmer la réception: %v", err)
} else if clientOut != username {
t.Errorf("client retourné: got=%q want=%q", clientOut, username)
}
}
// ── ValidateDeliveryAtomic : validation admin en masse ──────────────────────
//
// Divergence de règle métier volontaire (confirmée) : contrairement aux deux
// autres chemins d'approbation (client et staff), qui exigent tous deux le
// statut 'livre', ValidateDeliveryAtomic accepte "pending", "assigned",
// "en_route" ET "livre" — c'est un override admin assumé pour régulariser une
// commande gérée hors flux normal, pas un bug. Les tests suivants documentent
// ce comportement réel pour qu'une future régression involontaire soit détectée.
func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *testing.T) {
cleanupStockTestData(t)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
for _, status := range []string{"pending", "assigned", "en_route", "livre"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "validate_status_"+status)
productID := newTestProduct(t, "ValidateStatus"+status, 20)
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
pts, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test")
if err != nil {
t.Fatalf("ValidateDeliveryAtomic depuis le statut %q: %v", status, err)
}
if pts != 6 {
t.Errorf("points depuis statut %q: got=%d want=6", status, pts)
}
if got := commandStatus(t, cmdID); got != "approved" {
t.Errorf("statut final: got=%s want=approved", got)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points_extra depuis statut %q: got=%d want=6", status, got)
}
})
}
}
func TestValidateDeliveryAtomic_RejectsStatusOutsideAllowedList(t *testing.T) {
cleanupStockTestData(t)
for _, status := range []string{"cancelled", "pending_payment", "arrived"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "validate_invalid_"+status)
productID := newTestProduct(t, "ValidateInvalid"+status, 20)
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err == nil {
t.Fatalf("statut %q ne fait pas partie de la liste autorisée, attendu un rejet", status)
}
if got := commandStatus(t, cmdID); got != status {
t.Errorf("le statut ne doit pas changer: got=%s want=%s", got, status)
}
})
}
}
// Ici aussi la double-validation renvoie une vraie erreur ("commande déjà
// approuvée"), pas un no-op silencieux.
func TestValidateDeliveryAtomic_DoubleValidate_ReturnsErrorAndDoesNotDoublePoints(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "validate_double")
productID := newTestProduct(t, "ValidateDouble", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err != nil {
t.Fatalf("1ère validation: %v", err)
}
if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err == nil {
t.Fatal("la 2e validation sur une commande déjà approuvée doit renvoyer une erreur")
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points après double validation: got=%d want=6 (un seul crédit)", got)
}
}
func TestValidateDeliveryAtomic_ConcurrentValidate_CreditsPointsOnlyOnce(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "validate_concurrent")
productID := newTestProduct(t, "ValidateConcurrent", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
var wg sync.WaitGroup
n := 3
errs := make([]error, n)
ptsResults := make([]int, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ptsResults[idx], errs[idx] = testDB.ValidateDeliveryAtomic(cmdID, "admin_test")
}(i)
}
wg.Wait()
successCount := 0
for i := range n {
if errs[i] == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("une seule validation concurrente doit réussir: got=%d succès", successCount)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points après validations concurrentes: got=%d want=6 (un seul crédit)", got)
}
}
@@ -0,0 +1,404 @@
package tests
import (
"encoding/json"
"gestion/models"
"sync"
"testing"
"gorm.io/gorm"
)
// setPointsPoolsSettings remplace la configuration des pools de points pour la
// durée du test (table app_settings, clé "points_pools"), et restaure l'état
// par défaut en fin de test.
func setPointsPoolsSettings(t *testing.T, pools []models.PointsPool) {
t.Helper()
data, err := json.Marshal(pools)
if err != nil {
t.Fatalf("marshal pools: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO app_settings (key, value) VALUES ('points_pools', ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
string(data),
).Error; err != nil {
t.Fatalf("setPointsPoolsSettings: %v", err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_pools'`)
})
}
// setPointsRewardSettings configure le seuil de récompense globale (déduction
// de points lors de la consommation d'un article récompense).
func setPointsRewardSettings(t *testing.T, reward models.PointsReward) {
t.Helper()
data, err := json.Marshal(reward)
if err != nil {
t.Fatalf("marshal points_reward: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO app_settings (key, value) VALUES ('points_reward', ?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`,
string(data),
).Error; err != nil {
t.Fatalf("setPointsRewardSettings: %v", err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_reward'`)
})
}
// insertRewardCommandItem ajoute directement un item récompense à une
// commande déjà créée (contourne le checkout, pour isoler le calcul de points).
func insertRewardCommandItem(t *testing.T, commandID, productID int, quantite, prix float64, poolKey string) {
t.Helper()
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status, is_reward, reward_pool_key)
VALUES (?, ?, 'item reward test', ?, ?, 'pending', true, ?)`,
commandID, productID, quantite, prix, poolKey,
).Error; err != nil {
t.Fatalf("insertRewardCommandItem: %v", err)
}
}
func clientPointsExtra(t *testing.T, username string) map[string]int {
t.Helper()
extra, _, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
return extra
}
// ── CalculateAndAddPointsForCommandTx : logique bas niveau ──────────────────
func TestCalculateAndAddPointsForCommandTx_CreditsPointsPerPoolFromTiers(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "points_tiers_ok")
productID := newTestProduct(t, "PointsTiersOk", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{
{Min: 30, Max: 50, Points: 1},
{Min: 60, Max: 0, Points: 5},
}},
})
// Total commande = 60€ -> palier "60 et plus" = 5 points.
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60)
err := testDB.GDB.Transaction(func(tx *gorm.DB) error {
pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
if err != nil {
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
}
if pts != 5 {
t.Errorf("points calculés: got=%d want=5", pts)
}
if cat != "Pool Test" {
t.Errorf("catégorie: got=%q want=%q", cat, "Pool Test")
}
return nil
})
if err != nil {
t.Fatalf("transaction: %v", err)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 5 {
t.Errorf("points_extra[pool_0] après crédit: got=%d want=5", got)
}
}
func TestCalculateAndAddPointsForCommandTx_NoPoolsConfigured_ReturnsZero(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "points_no_pools")
productID := newTestProduct(t, "PointsNoPools", 20)
setPointsPoolsSettings(t, []models.PointsPool{}) // aucun pool configuré
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60)
testDB.GDB.Transaction(func(tx *gorm.DB) error {
pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
if err != nil {
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
}
if pts != 0 || cat != "" {
t.Errorf("sans pool configuré: got pts=%d cat=%q, want 0/\"\"", pts, cat)
}
return nil
})
if got := clientPointsExtra(t, username); len(got) != 0 {
t.Errorf("points_extra ne doit pas bouger sans pool configuré: got=%v", got)
}
}
// Un pool existe mais aucune de ses catégories ne correspond à la catégorie
// des produits commandés ("test", posée par newTestProduct) -> 0 point.
func TestCalculateAndAddPointsForCommandTx_CategoryNotInAnyPool_ReturnsZero(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "points_cat_mismatch")
productID := newTestProduct(t, "PointsCatMismatch", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Autre Catégorie", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{
{Min: 30, Max: 0, Points: 5},
}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60)
testDB.GDB.Transaction(func(tx *gorm.DB) error {
pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
if err != nil {
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
}
if pts != 0 {
t.Errorf("catégorie hors pool: got pts=%d want=0", pts)
}
return nil
})
if got := clientPointsExtra(t, username); len(got) != 0 {
t.Errorf("points_extra ne doit pas bouger si aucune catégorie ne matche: got=%v", got)
}
}
// Deux pools indépendants : seul celui dont la catégorie correspond aux
// produits de la commande doit recevoir des points.
func TestCalculateAndAddPointsForCommandTx_MultiplePoolsIndependent(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "points_multi_pool")
productID := newTestProduct(t, "PointsMultiPool", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Match", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 7}}},
{Key: "pool_1", Name: "Pool No Match", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{{Min: 30, Max: 0, Points: 99}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 2, 20)
testDB.GDB.Transaction(func(tx *gorm.DB) error {
pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
if err != nil {
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
}
if pts != 7 {
t.Errorf("total points (seul pool_0 doit contribuer): got=%d want=7", pts)
}
return nil
})
extra := clientPointsExtra(t, username)
if extra["pool_0"] != 7 {
t.Errorf("pool_0: got=%d want=7", extra["pool_0"])
}
if extra["pool_1"] != 0 {
t.Errorf("pool_1 ne doit recevoir aucun point (catégorie non matchée): got=%d want=0", extra["pool_1"])
}
}
// Un article récompense présent dans la commande déduit "threshold" points du
// pool correspondant, en plus des points gagnés par les articles payants de
// la même commande.
func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoints(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "points_reward_deduct")
paidProductID := newTestProduct(t, "PointsRewardDeductPaid", 20)
rewardProductID := newTestProduct(t, "PointsRewardDeductFree", 5)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 3}}},
})
setPointsRewardSettings(t, models.PointsReward{Threshold: 20, Type: "free_product"})
setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande
cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10)
insertRewardCommandItem(t, cmdID, rewardProductID, 1, 0, "pool_0")
testDB.GDB.Transaction(func(tx *gorm.DB) error {
pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
if err != nil {
t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err)
}
if pts != 3 {
t.Errorf("points gagnés sur l'article payant: got=%d want=3", pts)
}
return nil
})
// 25 (initial) + 3 (gagnés) - 20 (seuil déduit pour la récompense consommée) = 8.
if got := clientPointsExtra(t, username)["pool_0"]; got != 8 {
t.Errorf("points_extra[pool_0] après crédit + déduction récompense: got=%d want=8", got)
}
}
// Propriété documentée du design : cette fonction bas niveau n'a aucune garde
// d'idempotence intégrée — appeler deux fois pour la même commande double les
// points. C'est le rôle de l'appelant (ApproveDeliveryAtomic, via son
// verrou de transition de statut livre->approved) d'empêcher un second appel.
func TestCalculateAndAddPointsForCommandTx_CalledTwice_DoublesPoints(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "points_called_twice")
productID := newTestProduct(t, "PointsCalledTwice", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 4}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
for i := 0; i < 2; i++ {
testDB.GDB.Transaction(func(tx *gorm.DB) error {
_, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username)
if err != nil {
t.Fatalf("appel %d: %v", i+1, err)
}
return nil
})
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 8 {
t.Errorf("deux appels bruts doublent les points (4+4): got=%d want=8 — ceci documente pourquoi ApproveDeliveryAtomic doit rester le seul appelant", got)
}
}
// ── ApproveDeliveryAtomic : la vraie règle métier "points uniquement à
// l'approbation, jamais avant" ────────────────────────────────────────────
func TestApproveDeliveryAtomic_CreditsPointsExactlyOnApproval(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "approve_credits_points")
productID := newTestProduct(t, "ApproveCreditsPoints", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
pts, _, err := testDB.ApproveDeliveryAtomic(cmdID, username)
if err != nil {
t.Fatalf("ApproveDeliveryAtomic: %v", err)
}
if pts != 6 {
t.Errorf("points retournés par l'approbation: got=%d want=6", pts)
}
if got := commandStatus(t, cmdID); got != "approved" {
t.Errorf("statut après approbation: got=%s want=approved", got)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points_extra après approbation: got=%d want=6", got)
}
}
// La règle centrale : tant que la commande n'est pas "livre", l'approbation
// doit être rejetée et AUCUN point ne doit être crédité.
func TestApproveDeliveryAtomic_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) {
cleanupStockTestData(t)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
for _, status := range []string{"pending", "assigned", "en_route", "arrived"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "approve_reject_"+status)
productID := newTestProduct(t, "ApproveReject"+status, 20)
cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err == nil {
t.Fatalf("attendu un rejet pour une commande en statut %q", status)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 0 {
t.Errorf("aucun point ne doit être crédité pour une commande non 'livre' (statut=%s): got=%d want=0", status, got)
}
if got := commandStatus(t, cmdID); got != status {
t.Errorf("le statut ne doit pas changer sur une approbation rejetée: got=%s want=%s", got, status)
}
})
}
}
// Double approbation (retry réseau / double-tap client) : la seconde doit
// être un no-op silencieux, jamais un second crédit de points.
func TestApproveDeliveryAtomic_DoubleApprove_DoesNotDoublePoints(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "approve_double")
productID := newTestProduct(t, "ApproveDouble", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err != nil {
t.Fatalf("1ère approbation: %v", err)
}
pts2, _, err := testDB.ApproveDeliveryAtomic(cmdID, username)
if err != nil {
t.Fatalf("2e approbation (doit être idempotente, pas une erreur): %v", err)
}
if pts2 != 0 {
t.Errorf("2e approbation ne doit rapporter aucun point: got=%d want=0", pts2)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points après double approbation (doivent rester crédités une seule fois): got=%d want=6", got)
}
}
func TestApproveDeliveryAtomic_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "approve_concurrent")
productID := newTestProduct(t, "ApproveConcurrent", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10)
var wg sync.WaitGroup
n := 3
errs := make([]error, n)
ptsResults := make([]int, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
ptsResults[idx], _, errs[idx] = testDB.ApproveDeliveryAtomic(cmdID, username)
}(i)
}
wg.Wait()
// ApproveDeliveryAtomic traite une commande déjà approuvée comme un no-op
// idempotent (err=nil, pts=0), pas comme une erreur — donc le critère de
// "vrai succès" est pts>0 (crédit réellement appliqué), pas err==nil.
freshCreditCount := 0
for i := range n {
if errs[i] != nil {
t.Errorf("appel %d: erreur inattendue: %v", i, errs[i])
continue
}
if ptsResults[i] > 0 {
freshCreditCount++
}
}
if freshCreditCount != 1 {
t.Errorf("une seule approbation concurrente doit réellement créditer des points: got=%d", freshCreditCount)
}
if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
t.Errorf("points après approbations concurrentes: got=%d want=6 (un seul crédit)", got)
}
}
func TestApproveDeliveryAtomic_WrongOwnerRejected(t *testing.T) {
cleanupStockTestData(t)
owner := newTestClient(t, "approve_owner")
intruder := newTestClient(t, "approve_intruder")
productID := newTestProduct(t, "ApproveWrongOwner", 20)
setPointsPoolsSettings(t, []models.PointsPool{
{Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}},
})
cmdID := newTestCommandWithItem(t, owner, "livre", "", productID, 1, 10)
if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, intruder); err == nil {
t.Fatal("un client ne doit pas pouvoir approuver la commande d'un autre client")
}
if got := clientPointsExtra(t, intruder)["pool_0"]; got != 0 {
t.Errorf("l'intrus ne doit recevoir aucun point: got=%d want=0", got)
}
if got := clientPointsExtra(t, owner)["pool_0"]; got != 0 {
t.Errorf("le propriétaire ne doit pas non plus recevoir de point tant que ce n'est pas lui qui approuve: got=%d want=0", got)
}
if got := commandStatus(t, cmdID); got != "livre" {
t.Errorf("statut ne doit pas changer sur une tentative d'un intrus: got=%s want=livre", got)
}
}
+254
View File
@@ -0,0 +1,254 @@
package tests
import (
"sync"
"testing"
)
// setClientReferralBalance fixe directement le solde de crédit parrainage
// d'un client de test (contourne le flux normal de parrainage/checkout pour
// tester isolément le débit/crédit).
func setClientReferralBalance(t *testing.T, username string, amount float64) {
t.Helper()
if err := testDB.GDB.Exec(
`UPDATE clients SET referral_balance = ? WHERE username = ?`, amount, username,
).Error; err != nil {
t.Fatalf("setClientReferralBalance: %v", err)
}
}
func referralBalance(t *testing.T, username string) float64 {
t.Helper()
balance, err := testDB.GetClientReferralBalance(username)
if err != nil {
t.Fatalf("GetClientReferralBalance: %v", err)
}
return balance
}
// ── DebitReferralBalance ─────────────────────────────────────────────────────
func TestDebitReferralBalance_SucceedsWithSufficientBalance(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_debit_ok")
setClientReferralBalance(t, username, 50)
if err := testDB.DebitReferralBalance(username, 30); err != nil {
t.Fatalf("DebitReferralBalance: %v", err)
}
if got := referralBalance(t, username); got != 20 {
t.Errorf("solde après débit (50 - 30): got=%.2f want=20", got)
}
}
func TestDebitReferralBalance_FailsWithInsufficientBalance(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_debit_insuff")
setClientReferralBalance(t, username, 10)
if err := testDB.DebitReferralBalance(username, 30); err == nil {
t.Fatal("attendu une erreur (solde 10€ insuffisant pour débiter 30€)")
}
if got := referralBalance(t, username); got != 10 {
t.Errorf("solde ne doit pas bouger si le débit échoue: got=%.2f want=10", got)
}
}
// Un montant nul ou négatif est un no-op silencieux (cas "pas de crédit
// parrainage utilisé" au checkout) — ne doit jamais faire échouer ni modifier
// le solde.
func TestDebitReferralBalance_ZeroOrNegativeAmountIsNoop(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_debit_zero")
setClientReferralBalance(t, username, 15)
if err := testDB.DebitReferralBalance(username, 0); err != nil {
t.Errorf("montant nul ne doit jamais échouer: %v", err)
}
if err := testDB.DebitReferralBalance(username, -5); err != nil {
t.Errorf("montant négatif ne doit jamais échouer: %v", err)
}
if got := referralBalance(t, username); got != 15 {
t.Errorf("solde ne doit pas bouger sur un débit nul/négatif: got=%.2f want=15", got)
}
}
// Trois débits concurrents pour un solde qui ne permet qu'un seul d'entre eux
// ne doivent en laisser passer qu'un seul (verrou FOR UPDATE) — même classe de
// bug que le double-submit checkout, appliquée au solde de parrainage.
func TestDebitReferralBalance_ConcurrentDebitsDoNotOverspend(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_debit_concurrent")
setClientReferralBalance(t, username, 30)
var wg sync.WaitGroup
n := 3
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
errs[idx] = testDB.DebitReferralBalance(username, 30)
}(i)
}
wg.Wait()
successCount := 0
for _, err := range errs {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("un seul débit concurrent de 30€ sur un solde de 30€ doit réussir: got=%d succès", successCount)
}
if got := referralBalance(t, username); got != 0 {
t.Errorf("solde final après débits concurrents: got=%.2f want=0 (un seul débit appliqué)", got)
}
}
// ── CreditClientReferral ─────────────────────────────────────────────────────
func TestCreditClientReferral_AddsAmount(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_credit_ok")
setClientReferralBalance(t, username, 10)
if err := testDB.CreditClientReferral(username, 25); err != nil {
t.Fatalf("CreditClientReferral: %v", err)
}
if got := referralBalance(t, username); got != 35 {
t.Errorf("solde après crédit (10 + 25): got=%.2f want=35", got)
}
}
func TestCreditClientReferral_RejectsNonPositiveAmount(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_credit_negative")
setClientReferralBalance(t, username, 10)
if err := testDB.CreditClientReferral(username, 0); err == nil {
t.Error("un crédit de montant nul doit être rejeté")
}
if err := testDB.CreditClientReferral(username, -5); err == nil {
t.Error("un crédit de montant négatif doit être rejeté")
}
if got := referralBalance(t, username); got != 10 {
t.Errorf("solde ne doit pas bouger sur un crédit rejeté: got=%.2f want=10", got)
}
}
func TestCreditClientReferral_FailsForUnknownClient(t *testing.T) {
if err := testDB.CreditClientReferral(testUserPrefix+"does_not_exist", 10); err == nil {
t.Fatal("attendu une erreur pour un client inexistant")
}
}
// ── ResetClientReferralBalance ───────────────────────────────────────────────
func TestResetClientReferralBalance_SetsToZero(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_reset")
setClientReferralBalance(t, username, 42)
if err := testDB.ResetClientReferralBalance(username); err != nil {
t.Fatalf("ResetClientReferralBalance: %v", err)
}
if got := referralBalance(t, username); got != 0 {
t.Errorf("solde après reset: got=%.2f want=0", got)
}
}
// ── Séquence complète débit -> checkout, reproduisant exactement le flux du
// handler ValidateBasket (handlers/panier.go) : débit AVANT la création de la
// commande, puis re-crédit compensatoire si CreateCommandWithAddress échoue.
// Sans ce re-crédit, un client perdrait sèchement son crédit de parrainage
// sur un checkout qui a pourtant échoué (violation explicite de la doc métier).
func TestReferral_DebitThenCheckoutFailure_RecreditsBalance(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_checkout_fail")
productID := newTestProduct(t, "ReferralCheckoutFail", 1)
setClientReferralBalance(t, username, 20)
insertNormalBasketRow(t, username, productID, 5, 50) // 5 demandés pour 1 en stock -> échec garanti
referralUsed := 20.0
if err := testDB.DebitReferralBalance(username, referralUsed); err != nil {
t.Fatalf("DebitReferralBalance: %v", err)
}
if got := referralBalance(t, username); got != 0 {
t.Fatalf("précondition: solde débité: got=%.2f want=0", got)
}
_, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err == nil {
t.Fatal("attendu un échec de checkout (stock insuffisant)")
}
// Reproduction exacte de la compensation faite par le handler en cas d'échec.
if err := testDB.CreditClientReferral(username, referralUsed); err != nil {
t.Fatalf("CreditClientReferral (compensation): %v", err)
}
if got := referralBalance(t, username); got != 20 {
t.Errorf("le crédit parrainage doit être intégralement restauré après échec du checkout: got=%.2f want=20", got)
}
if got := productStock(t, productID); got != 1 {
t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=1", got)
}
}
func TestReferral_DebitThenCheckoutSuccess_BalanceStaysDebited(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_checkout_ok")
productID := newTestProduct(t, "ReferralCheckoutOk", 10)
setClientReferralBalance(t, username, 20)
insertNormalBasketRow(t, username, productID, 3, 30)
if err := testDB.DebitReferralBalance(username, 20); err != nil {
t.Fatalf("DebitReferralBalance: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
if got := referralBalance(t, username); got != 0 {
t.Errorf("le crédit parrainage reste débité après un checkout réussi: got=%.2f want=0", got)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après checkout réussi: got=%.2f want=7", got)
}
}
// Combine les deux règles demandées explicitement : réclamation de
// récompense (même produit en reward + en achat normal) ET utilisation du
// crédit de parrainage sur la même commande.
func TestReferral_CombinedWithSameProductRewardAndNormal_AllInvariantsHold(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "referral_combined_reward")
productID := newTestProduct(t, "ReferralCombinedReward", 20)
setClientReferralBalance(t, username, 15)
insertRewardBasketRow(t, username, productID, 1)
insertNormalBasketRow(t, username, productID, 4, 40)
if err := testDB.DebitReferralBalance(username, 15); err != nil {
t.Fatalf("DebitReferralBalance: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
// 20 initial - 1 (reward) - 4 (normal) = 15.
if got := productStock(t, productID); got != 15 {
t.Errorf("stock après checkout combiné (reward+normal même produit + parrainage): got=%.2f want=15", got)
}
if got := referralBalance(t, username); got != 0 {
t.Errorf("crédit parrainage débité et non restauré après succès: got=%.2f want=0", got)
}
}
@@ -0,0 +1,132 @@
package tests
import (
"bytes"
"encoding/json"
"gestion/db"
"gestion/handlers"
"gestion/models"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/points/claim", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", username)
return c, rec
}
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
t.Helper()
settings := db.DefaultSettings()
settings.PointsReward = reward
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings: %v", err)
}
}
// 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.
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,
Type: "free_product",
Description: "Un produit offert",
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
})
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())
}
var resp struct {
Success bool `json:"success"`
RemainingRewards int `json:"remaining_rewards"`
ProductAdded bool `json:"product_added"`
}
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 || !resp.ProductAdded {
t.Fatalf("réclamation devrait réussir avec produit ajouté: %+v", resp)
}
if resp.RemainingRewards != 0 {
t.Errorf("remaining_rewards: got=%d want=0", resp.RemainingRewards)
}
rows := basketRewardItems(t, username)
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
t.Errorf("le produit récompense doit être dans le panier: %+v", rows)
}
}
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_below")
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Type: "free_product",
RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
})
setClientPoolPoints(t, username, "pool_0", 5)
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())
}
}
// 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).
func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_http_missing_product")
configureRewardSettings(t, &models.PointsReward{
Threshold: 20,
Type: "free_product",
RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
})
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("la réclamation ne doit pas réussir avec un produit récompense invalide, body=%s", rec.Body.String())
}
_, 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 le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"])
}
}
+411
View File
@@ -0,0 +1,411 @@
package tests
import (
"gestion/models"
"strings"
"sync"
"testing"
)
// setClientPoolPoints fixe directement les points cumulés d'un client pour un
// pool donné (contourne le flux normal d'accumulation pour tester isolément
// la réclamation de récompense).
func setClientPoolPoints(t *testing.T, username, poolKey string, points int) {
t.Helper()
if err := testDB.GDB.Exec(
`UPDATE clients SET points_extra = jsonb_set(COALESCE(points_extra, '{}'::jsonb), ARRAY[?], to_jsonb(?::int)) WHERE username = ?`,
poolKey, points, username,
).Error; err != nil {
t.Fatalf("setClientPoolPoints: %v", err)
}
}
type rewardBasketRow struct {
ProductID int `gorm:"column:product_id"`
Quantity float64 `gorm:"column:quantity"`
Price float64 `gorm:"column:price"`
IsReward bool `gorm:"column:is_reward"`
RewardPoolKey string `gorm:"column:reward_pool_key"`
}
func basketRewardItems(t *testing.T, username string) []rewardBasketRow {
t.Helper()
var rows []rewardBasketRow
if err := testDB.GDB.Raw(
`SELECT product_id, quantity, price, is_reward, reward_pool_key
FROM baskets WHERE username = ? AND is_reward = true`, username,
).Scan(&rows).Error; err != nil {
t.Fatalf("lecture panier récompense: %v", err)
}
return rows
}
// ── ClaimPoolReward : seuil, atomicité, épuisement ──────────────────────────
func TestClaimPoolReward_BelowThresholdFails(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_below_threshold")
setClientPoolPoints(t, username, "pool_0", 19)
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
t.Fatal("attendu une erreur : 19 points < seuil 20")
} else if !strings.Contains(err.Error(), "pas de récompense disponible") {
t.Errorf("message d'erreur inattendu: %v", err)
}
}
func TestClaimPoolReward_ExactlyAtThresholdSucceeds(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_exact_threshold")
setClientPoolPoints(t, username, "pool_0", 20)
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
if err != nil {
t.Fatalf("ClaimPoolReward: %v", err)
}
if remaining != 0 {
t.Errorf("remaining: got=%d want=0 (1 récompense gagnée, 1 réclamée)", remaining)
}
}
func TestClaimPoolReward_MultipleRewardsEarned(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_multiple")
setClientPoolPoints(t, username, "pool_0", 45) // 45/20 = 2 récompenses gagnées
remaining1, err := testDB.ClaimPoolReward(username, "pool_0", 20)
if err != nil {
t.Fatalf("1er claim: %v", err)
}
if remaining1 != 1 {
t.Errorf("après 1er claim: got=%d want=1", remaining1)
}
remaining2, err := testDB.ClaimPoolReward(username, "pool_0", 20)
if err != nil {
t.Fatalf("2e claim: %v", err)
}
if remaining2 != 0 {
t.Errorf("après 2e claim: got=%d want=0", remaining2)
}
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil {
t.Fatal("3e claim: attendu une erreur (récompenses épuisées)")
}
}
// Trois réclamations concurrentes pour un client n'ayant droit qu'à UNE seule
// récompense ne doivent en laisser passer qu'une seule (verrou FOR UPDATE).
func TestClaimPoolReward_ConcurrentClaimsDoNotOverclaim(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_concurrent")
setClientPoolPoints(t, username, "pool_0", 20) // 1 seule récompense disponible
var wg sync.WaitGroup
n := 3
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, errs[idx] = testDB.ClaimPoolReward(username, "pool_0", 20)
}(i)
}
wg.Wait()
successCount := 0
for _, err := range errs {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("un seul claim concurrent doit réussir: got=%d succès", successCount)
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 1 {
t.Errorf("compteur redeemed après claims concurrents: got=%d want=1", redeemed["pool_0"])
}
}
// Les pools sont indépendants : les points d'un pool ne doivent pas permettre
// de réclamer une récompense sur un autre pool.
func TestClaimPoolReward_PoolsAreIndependent(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_pool_isolation")
setClientPoolPoints(t, username, "pool_0", 20)
// pool_1 n'a aucun point.
if _, err := testDB.ClaimPoolReward(username, "pool_1", 20); err == nil {
t.Fatal("attendu une erreur : aucun point sur pool_1")
}
if remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
t.Errorf("pool_0 devrait rester réclamable: %v", err)
} else if remaining != 0 {
t.Errorf("remaining pool_0: got=%d want=0", remaining)
}
}
// ── ClaimPoolRewardAndAddToBasket : atomicité réclamation + livraison ───────
func TestClaimPoolRewardAndAddToBasket_SucceedsAndDecrementsAvailable(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_combined_ok")
productID := newTestProduct(t, "RewardCombinedOk", 5)
setClientPoolPoints(t, username, "pool_0", 20)
remaining, added, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
[]models.RewardItem{{ProductID: productID, Quantity: 1, Price: 10}})
if err != nil {
t.Fatalf("ClaimPoolRewardAndAddToBasket: %v", err)
}
if remaining != 0 {
t.Errorf("remaining: got=%d want=0", remaining)
}
if len(added) != 1 {
t.Fatalf("articles ajoutés: got=%d want=1", len(added))
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 1 {
t.Errorf("redeemed: got=%d want=1", redeemed["pool_0"])
}
}
// Si le produit récompense est introuvable, ni la récompense ni le panier ne
// doivent être modifiés (rollback complet de la transaction combinée).
func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_combined_rollback")
setClientPoolPoints(t, username, "pool_0", 20)
_, _, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20,
[]models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}})
if err == nil {
t.Fatal("attendu une erreur pour un produit récompense inexistant")
}
_, 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 l'ajout au panier échoue: got redeemed=%d want=0", redeemed["pool_0"])
}
if rows := basketRewardItems(t, username); len(rows) != 0 {
t.Errorf("aucun article récompense ne doit rester en panier: got=%d", len(rows))
}
}
// ── AddRewardsToBasket : flags et remplacement ──────────────────────────────
func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_flags")
productID := newTestProduct(t, "RewardBasketFlags", 20)
items := []models.RewardItem{{ProductID: productID, Quantity: 2, Price: 15.0}}
added, err := testDB.AddRewardsToBasket(username, items, "pool_0")
if err != nil {
t.Fatalf("AddRewardsToBasket: %v", err)
}
if len(added) != 1 {
t.Fatalf("nombre d'articles ajoutés: got=%d want=1", len(added))
}
rows := basketRewardItems(t, username)
if len(rows) != 1 {
t.Fatalf("articles récompense en base: got=%d want=1", len(rows))
}
row := rows[0]
if !row.IsReward {
t.Error("is_reward doit être true")
}
if row.RewardPoolKey != "pool_0" {
t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0")
}
if row.Price != 0 {
t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price)
}
if row.Quantity != 2 {
t.Errorf("quantité: got=%.2f want=2", row.Quantity)
}
}
// Réclamer une nouvelle récompense doit remplacer les articles récompense
// précédents, pas les cumuler (évite d'accumuler indéfiniment des articles
// gratuits si le client reclique plusieurs fois).
func TestAddRewardsToBasket_ReplacesPreviousRewardItems(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_replace")
productA := newTestProduct(t, "RewardReplaceA", 20)
productB := newTestProduct(t, "RewardReplaceB", 20)
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productA, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
t.Fatalf("1er AddRewardsToBasket: %v", err)
}
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productB, Quantity: 1, Price: 10}}, "pool_0"); err != nil {
t.Fatalf("2e AddRewardsToBasket: %v", err)
}
rows := basketRewardItems(t, username)
if len(rows) != 1 {
t.Fatalf("un seul article récompense doit rester après remplacement: got=%d", len(rows))
}
if rows[0].ProductID != productB {
t.Errorf("l'article récompense restant doit être le dernier réclamé: got=%d want=%d", rows[0].ProductID, productB)
}
}
// Un article récompense pointant vers un produit inexistant doit faire
// échouer l'ajout, sans rien insérer du tout (transaction).
func TestAddRewardsToBasket_FailsOnUnknownProduct(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_unknown")
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}}, "pool_0"); err == nil {
t.Fatal("attendu une erreur pour un produit inexistant")
}
rows := basketRewardItems(t, username)
if len(rows) != 0 {
t.Errorf("aucun article ne doit être ajouté si le produit est introuvable: got=%d", len(rows))
}
}
// ── Chaîne complète : réclamation -> panier -> checkout -> stock ────────────
// C'est le scénario demandé explicitement : vérifier que le stock est bien
// déduit pour un article obtenu par récompense, exactement comme un article
// payant (règle métier explicite : les récompenses ne sont jamais exclues du
// décompte de stock).
func TestRewardClaim_FullChain_DecrementsStockAtCheckout(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_full_chain")
rewardProductID := newTestProduct(t, "RewardFullChainFree", 5)
paidProductID := newTestProduct(t, "RewardFullChainPaid", 10)
setClientPoolPoints(t, username, "pool_0", 20)
remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20)
if err != nil {
t.Fatalf("ClaimPoolReward: %v", err)
}
if remaining != 0 {
t.Errorf("remaining: got=%d want=0", remaining)
}
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
t.Fatalf("AddRewardsToBasket: %v", err)
}
if _, err := testDB.AddToBasket(username, paidProductID, 3); err != nil {
t.Fatalf("AddToBasket (article payant): %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
if got := productStock(t, rewardProductID); got != 3 {
t.Errorf("stock article récompense après checkout (5 initial - 2 offerts): got=%.2f want=3", got)
}
if got := productStock(t, paidProductID); got != 7 {
t.Errorf("stock article payant après checkout (10 initial - 3 achetés): got=%.2f want=7", got)
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 1 {
t.Errorf("compteur de récompenses réclamées après checkout: got=%d want=1", redeemed["pool_0"])
}
}
// Si le checkout échoue (stock insuffisant sur l'article payant du même
// panier), l'article récompense ne doit pas non plus voir son stock décrémenté
// (rollback complet, cohérent avec le comportement déjà vérifié pour les
// articles payants).
func TestRewardClaim_CheckoutFailure_DoesNotDecrementRewardStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_checkout_fail")
rewardProductID := newTestProduct(t, "RewardCheckoutFailFree", 5)
shortProductID := newTestProduct(t, "RewardCheckoutFailShort", 1)
setClientPoolPoints(t, username, "pool_0", 20)
if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil {
t.Fatalf("ClaimPoolReward: %v", err)
}
if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil {
t.Fatalf("AddRewardsToBasket: %v", err)
}
// Article payant en rupture pour forcer l'échec du checkout.
if err := testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`,
username, shortProductID,
).Error; err != nil {
t.Fatalf("insertion panier insuffisant: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil {
t.Fatal("attendu un échec de checkout (stock insuffisant sur l'article payant)")
}
if got := productStock(t, rewardProductID); got != 5 {
t.Errorf("stock article récompense ne doit pas bouger si le checkout échoue: got=%.2f want=5", got)
}
}
// ── ResetClientRedeemed (admin) ──────────────────────────────────────────────
func TestResetClientRedeemed_SpecificPool(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_reset_specific")
setClientPoolPoints(t, username, "pool_0", 20)
setClientPoolPoints(t, username, "pool_1", 20)
testDB.ClaimPoolReward(username, "pool_0", 20)
testDB.ClaimPoolReward(username, "pool_1", 20)
if err := testDB.ResetClientRedeemed(username, "pool_0"); err != nil {
t.Fatalf("ResetClientRedeemed: %v", err)
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if redeemed["pool_0"] != 0 {
t.Errorf("pool_0 doit être remis à zéro: got=%d", redeemed["pool_0"])
}
if redeemed["pool_1"] != 1 {
t.Errorf("pool_1 ne doit pas être affecté: got=%d want=1", redeemed["pool_1"])
}
}
func TestResetClientRedeemed_AllPools(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_reset_all")
setClientPoolPoints(t, username, "pool_0", 20)
setClientPoolPoints(t, username, "pool_1", 20)
testDB.ClaimPoolReward(username, "pool_0", 20)
testDB.ClaimPoolReward(username, "pool_1", 20)
if err := testDB.ResetClientRedeemed(username, ""); err != nil {
t.Fatalf("ResetClientRedeemed: %v", err)
}
_, redeemed, err := testDB.GetClientPointsAndRewards(username)
if err != nil {
t.Fatalf("GetClientPointsAndRewards: %v", err)
}
if len(redeemed) != 0 {
t.Errorf("tous les pools doivent être remis à zéro: got=%v", redeemed)
}
}
@@ -0,0 +1,162 @@
package tests
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"gestion/models"
"github.com/gin-gonic/gin"
)
func adminStatsContext() (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
return c, rec
}
// insertStatsCommand insère une commande directement avec un created_at
// choisi, pour contrôler précisément le jour de semaine/heure agrégés.
func insertStatsCommand(t *testing.T, username, status, createdAt string) {
t.Helper()
testDB.GDB.Exec(
`INSERT INTO commandes (username, status, adresse, total_prix, created_at, updated_at)
VALUES (?, ?, 'Adresse stats test', 10, ?::timestamp, ?::timestamp)`,
username, status, createdAt, createdAt,
)
}
func TestGetAdminStats_ZeroOrdersAvgPerDayIsZeroNoPanic(t *testing.T) {
cleanupStockTestData(t)
// Table commandes peut contenir des données d'autres tests, mais aucune
// n'utilise ce username isolé — on vérifie juste l'absence de panic/NaN
// et que la structure de réponse est bien formée avec les vraies
// données actuelles de la base de test (qui peut être non-vide).
c, rec := adminStatsContext()
handlers.GetAdminStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Summary struct {
AvgPerDay float64 `json:"avg_per_day"`
} `json:"summary"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("réponse JSON invalide: %v", err)
}
if resp.Summary.AvgPerDay < 0 {
t.Errorf("avg_per_day ne doit jamais être négatif: got=%f", resp.Summary.AvgPerDay)
}
}
func TestGetAdminStats_PeakWeekdayMatchesBusiestDay(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_peak")
// 2024-01-08 est un lundi, 2024-01-07 un dimanche (DOW Postgres: 0=dimanche).
insertStatsCommand(t, username, "pending", "2024-01-08 10:00:00")
insertStatsCommand(t, username, "pending", "2024-01-08 11:00:00")
insertStatsCommand(t, username, "pending", "2024-01-08 12:00:00")
insertStatsCommand(t, username, "pending", "2024-01-07 10:00:00")
c, rec := adminStatsContext()
handlers.GetAdminStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
Summary struct {
PeakWeekday string `json:"peak_weekday"`
} `json:"summary"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("réponse JSON invalide: %v", err)
}
if resp.Summary.PeakWeekday != "Lundi" {
t.Errorf("peak_weekday doit être le jour avec le plus de commandes: got=%q want=Lundi", resp.Summary.PeakWeekday)
}
}
func TestGetAdminStats_ByQuantitySortedDescendingByTotalOrders(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_byqty")
prodLow := newTestProduct(t, "ByQtyLow", 100)
prodHigh := newTestProduct(t, "ByQtyHigh", 100)
// prodHigh: 3 commandes ; prodLow: 1 commande.
for range 3 {
newTestCommandWithItem(t, username, "pending", "", prodHigh, 1, 10)
}
newTestCommandWithItem(t, username, "pending", "", prodLow, 1, 10)
c, rec := adminStatsContext()
handlers.GetAdminStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var resp struct {
ByQuantity []struct {
ProductID int `json:"product_id"`
TotalOrders int `json:"total_orders"`
} `json:"by_quantity"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("réponse JSON invalide: %v", err)
}
// Vérifie l'ordre décroissant global (pas seulement nos deux produits,
// d'autres tests peuvent avoir laissé des données) et que prodHigh
// apparaît avant prodLow.
highIdx, lowIdx := -1, -1
for i, r := range resp.ByQuantity {
if r.ProductID == prodHigh {
highIdx = i
}
if r.ProductID == prodLow {
lowIdx = i
}
if i > 0 && r.TotalOrders > resp.ByQuantity[i-1].TotalOrders {
t.Errorf("by_quantity doit être trié par total_orders décroissant: rupture à l'index %d", i)
}
}
if highIdx == -1 || lowIdx == -1 {
t.Fatalf("les deux produits de test doivent apparaître dans by_quantity: highIdx=%d lowIdx=%d", highIdx, lowIdx)
}
if highIdx >= lowIdx {
t.Errorf("le produit avec le plus de commandes doit apparaître avant: highIdx=%d lowIdx=%d", highIdx, lowIdx)
}
}
func TestOrdersAndRevenueByHour_CountsNonCancelledRevenueOnlyApproved(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_hour")
insertStatsCommand(t, username, "approved", "2024-01-08 14:00:00")
insertStatsCommand(t, username, "pending", "2024-01-08 14:30:00")
insertStatsCommand(t, username, "cancelled", "2024-01-08 14:45:00")
testDB.GDB.Exec(`UPDATE commandes SET total_prix = 25 WHERE username = ? AND status = 'approved'`, username)
var hourRows []models.HourRow
if err := testDB.OrdersAndRevenueByHour(&hourRows, testDB.ReadResetAt("stats_reset_heures_at")); err != nil {
t.Fatalf("OrdersAndRevenueByHour: %v", err)
}
var found bool
for _, r := range hourRows {
if r.Hour == 14 {
found = true
if r.Count != 2 {
t.Errorf("count à 14h doit exclure la commande annulée: got=%d want=2", r.Count)
}
if r.Revenue != 25 {
t.Errorf("revenue à 14h ne doit compter que les commandes approuvées: got=%.2f want=25", r.Revenue)
}
}
}
if !found {
t.Fatal("aucune ligne pour l'heure 14 trouvée")
}
}
@@ -0,0 +1,59 @@
package tests
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
func statsSectionContext(section string) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/stats/reset", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Params = gin.Params{{Key: "section", Value: section}}
return c, rec
}
func TestResetAdminStats_RejectsInvalidSection(t *testing.T) {
c, rec := statsSectionContext("section-inexistante")
handlers.ResetAdminStats(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("section invalide doit retourner 400: got=%d body=%s", rec.Code, rec.Body.String())
}
}
func TestResetAdminStats_SuccessResetsSection(t *testing.T) {
c, rec := statsSectionContext("commandes")
handlers.ResetAdminStats(c)
if rec.Code != http.StatusOK {
t.Fatalf("reset d'une section valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if !bytes.Contains(rec.Body.Bytes(), []byte(`"success":true`)) {
t.Errorf("réponse doit indiquer success: body=%s", rec.Body.String())
}
resetAt := testDB.ReadResetAt("stats_reset_commandes_at")
if resetAt.IsZero() {
t.Error("le timestamp de reset doit être renseigné après ResetAdminStats")
}
}
func TestGetMyDeliveryStats_RejectsNonLivreurRole(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", nil)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"stats_role")
c.Set("role", "client")
handlers.GetMyDeliveryStats(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
+104
View File
@@ -0,0 +1,104 @@
package tests
import (
"testing"
"time"
)
// ResetAdminStat déplace le point de coupure temporel utilisé par toutes les
// requêtes de stats (TotalOrders, TotalRevenue, ActiveDaysLast30, ...) : les
// commandes créées AVANT le reset doivent disparaître des totaux, celles
// créées APRÈS doivent rester visibles. C'est le mécanisme central derrière
// le bouton "reset stats" de l'admin — jamais testé jusqu'ici.
const statsResetTestKey = "stats_reset_commandes_at"
func cleanupStatsResetKey(t *testing.T, key string) {
t.Helper()
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = ?`, key)
})
}
func TestResetAdminStat_TotalOrdersExcludesOrdersBeforeReset(t *testing.T) {
cleanupStockTestData(t)
cleanupStatsResetKey(t, statsResetTestKey)
username := newTestClient(t, "stat_reset_orders")
productID := newTestProduct(t, "StatResetOrders", 10)
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
time.Sleep(1100 * time.Millisecond) // RFC3339 stocké sans fraction de seconde : marge nécessaire
if err := testDB.ResetAdminStat(statsResetTestKey); err != nil {
t.Fatalf("ResetAdminStat: %v", err)
}
time.Sleep(1100 * time.Millisecond)
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
totalWithoutFilter, err := testDB.TotalOrders(time.Time{})
if err != nil {
t.Fatalf("TotalOrders(zero): %v", err)
}
if totalWithoutFilter != 2 {
t.Fatalf("précondition: 2 commandes doivent exister sans filtre: got=%d", totalWithoutFilter)
}
resetAt := testDB.ReadResetAt(statsResetTestKey)
if resetAt.IsZero() {
t.Fatal("ReadResetAt ne doit pas être zero après ResetAdminStat")
}
totalAfterReset, err := testDB.TotalOrders(resetAt)
if err != nil {
t.Fatalf("TotalOrders(resetAt): %v", err)
}
if totalAfterReset != 1 {
t.Errorf("après reset, seule la commande créée après doit être comptée: got=%d want=1", totalAfterReset)
}
}
// Chaque section de stats a sa propre clé de reset (commandes, revenus,
// produits, heures, jours, doses) — réinitialiser l'une ne doit jamais
// affecter les autres.
func TestResetAdminStat_DifferentSectionsAreIndependent(t *testing.T) {
cleanupStatsResetKey(t, "stats_reset_commandes_at_test_indep")
cleanupStatsResetKey(t, "stats_reset_revenus_at_test_indep")
if err := testDB.ResetAdminStat("stats_reset_commandes_at_test_indep"); err != nil {
t.Fatalf("ResetAdminStat (commandes): %v", err)
}
if got := testDB.ReadResetAt("stats_reset_revenus_at_test_indep"); !got.IsZero() {
t.Errorf("réinitialiser la section commandes ne doit pas créer de reset pour revenus: got=%v", got)
}
if got := testDB.ReadResetAt("stats_reset_commandes_at_test_indep"); got.IsZero() {
t.Error("la section commandes doit bien avoir une date de reset")
}
}
// ActiveDaysLast30 (stat secondaire) doit respecter le même filtre de reset
// que TotalOrders : un jour dont l'unique commande a été passée avant le
// reset ne doit plus compter comme jour actif.
func TestActiveDaysLast30_RespectsResetFilter(t *testing.T) {
cleanupStockTestData(t)
cleanupStatsResetKey(t, statsResetTestKey)
username := newTestClient(t, "stat_reset_active_days")
productID := newTestProduct(t, "StatResetActiveDays", 10)
newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
time.Sleep(1100 * time.Millisecond)
if err := testDB.ResetAdminStat(statsResetTestKey); err != nil {
t.Fatalf("ResetAdminStat: %v", err)
}
resetAt := testDB.ReadResetAt(statsResetTestKey)
activeDays, err := testDB.ActiveDaysLast30(resetAt)
if err != nil {
t.Fatalf("ActiveDaysLast30: %v", err)
}
if activeDays != 0 {
t.Errorf("aucun jour actif ne doit être compté (seule commande antérieure au reset): got=%d want=0", activeDays)
}
}
+208
View File
@@ -0,0 +1,208 @@
package tests
import (
"gestion/models"
"testing"
"time"
)
// newTestOrderForStats crée directement une commande (+ un item) avec un
// statut, un total, un crédit de parrainage utilisé et une date de création
// contrôlés, pour tester isolément les calculs de stats admin.
func newTestOrderForStats(t *testing.T, username, status string, productID int, quantite, prix, referralUsed float64, createdAt time.Time) int {
t.Helper()
var cmdID int
if err := testDB.GDB.Raw(
`INSERT INTO commandes (username, status, adresse, total_prix, referral_used, created_at, updated_at)
VALUES (?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`,
username, status, prix, referralUsed, createdAt, createdAt,
).Scan(&cmdID).Error; err != nil {
t.Fatalf("création commande stats test: %v", err)
}
if err := testDB.GDB.Exec(
`INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
VALUES (?, ?, 'item test', ?, ?, 'pending')`,
cmdID, productID, quantite, prix,
).Error; err != nil {
t.Fatalf("création item stats test: %v", err)
}
return cmdID
}
// TotalRevenue ne compte que les commandes approuvées, nettes du crédit de
// parrainage utilisé — les commandes annulées ou encore en cours sont exclues.
func TestTotalRevenue_NetsReferralAndExcludesNonApproved(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_total_revenue")
productID := newTestProduct(t, "StatsTotalRevenue", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productID, 5, 100, 30, now) // net 70
newTestOrderForStats(t, username, "approved", productID, 2, 50, 0, now) // net 50
newTestOrderForStats(t, username, "cancelled", productID, 9, 999, 0, now) // exclue
newTestOrderForStats(t, username, "pending", productID, 9, 999, 0, now) // exclue (pas encore approuvée)
total, err := testDB.TotalRevenue(time.Time{})
if err != nil {
t.Fatalf("TotalRevenue: %v", err)
}
if total != 120 {
t.Errorf("TotalRevenue: got=%.2f want=120 (70+50, parrainage déduit, annulée/pending exclues)", total)
}
}
// TotalOrders exclut uniquement les commandes annulées (contrairement à
// TotalRevenue, il compte aussi les commandes non encore approuvées).
func TestTotalOrders_ExcludesOnlyCancelled(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_total_orders")
productID := newTestProduct(t, "StatsTotalOrders", 100)
now := time.Now()
newTestOrderForStats(t, username, "pending", productID, 1, 10, 0, now)
newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, now)
newTestOrderForStats(t, username, "cancelled", productID, 1, 10, 0, now)
total, err := testDB.TotalOrders(time.Time{})
if err != nil {
t.Fatalf("TotalOrders: %v", err)
}
if total != 2 {
t.Errorf("TotalOrders: got=%d want=2 (pending+approved, cancelled exclue)", total)
}
}
// Régression du bug corrigé : le revenu par produit/catégorie (TopProducts,
// QuantityBreakdown, DailyProductDetailForDate) doit rester cohérent avec
// TotalRevenue même quand une commande approuvée a utilisé du crédit de
// parrainage — avant le fix, ces trois vues sommaient ci.prix brut sans
// déduire referral_used, produisant un total supérieur au résumé global.
func TestProductBreakdowns_RevenueMatchesTotalRevenue_WithReferralUsed(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_breakdown_referral")
productA := newTestProduct(t, "StatsBreakdownA", 100)
productB := newTestProduct(t, "StatsBreakdownB", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productA, 5, 100, 30, now) // net 70
newTestOrderForStats(t, username, "approved", productB, 2, 50, 0, now) // net 50
totalRevenue, err := testDB.TotalRevenue(time.Time{})
if err != nil {
t.Fatalf("TotalRevenue: %v", err)
}
if totalRevenue != 120 {
t.Fatalf("précondition TotalRevenue: got=%.2f want=120", totalRevenue)
}
var prodRows []models.ProductRow
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
t.Fatalf("TopProducts: %v", err)
}
sumTop := 0.0
for _, r := range prodRows {
sumTop += r.Revenue
}
if sumTop != totalRevenue {
t.Errorf("somme TopProducts.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumTop, totalRevenue)
}
var qtyRows []models.QuantityBreakdownRow
if err := testDB.QuantityBreakdown(&qtyRows, time.Time{}); err != nil {
t.Fatalf("QuantityBreakdown: %v", err)
}
sumQty := 0.0
for _, r := range qtyRows {
sumQty += r.Revenue
}
if sumQty != totalRevenue {
t.Errorf("somme QuantityBreakdown.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumQty, totalRevenue)
}
var dailyRows []models.DailyProductRow
if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil {
t.Fatalf("DailyProductDetailForDate: %v", err)
}
sumDaily := 0.0
for _, r := range dailyRows {
sumDaily += r.Revenue
}
if sumDaily != totalRevenue {
t.Errorf("somme DailyProductDetailForDate.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumDaily, totalRevenue)
}
}
// Cas limite : commande entièrement couverte par le crédit de parrainage
// (total_prix == referral_used) — la part de revenu attribuée à l'article
// doit être 0, sans division par zéro ni erreur SQL.
func TestProductBreakdowns_FullyCoveredByReferralYieldsZeroRevenue(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_breakdown_full_referral")
productID := newTestProduct(t, "StatsBreakdownFullReferral", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productID, 4, 40, 40, now)
var prodRows []models.ProductRow
if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
t.Fatalf("TopProducts: %v", err)
}
if len(prodRows) != 1 {
t.Fatalf("attendu 1 produit, got=%d", len(prodRows))
}
if prodRows[0].Revenue != 0 {
t.Errorf("revenu attendu à 0 pour une commande entièrement couverte par le parrainage: got=%.2f", prodRows[0].Revenue)
}
}
// RevenueByDayLast30 doit rester cohérent avec TotalRevenue pour des
// commandes créées aujourd'hui (dans la fenêtre des 30 derniers jours).
func TestRevenueByDayLast30_MatchesTotalRevenue(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_revenue_by_day")
productID := newTestProduct(t, "StatsRevenueByDay", 100)
now := time.Now()
newTestOrderForStats(t, username, "approved", productID, 3, 90, 20, now) // net 70
totalRevenue, err := testDB.TotalRevenue(time.Time{})
if err != nil {
t.Fatalf("TotalRevenue: %v", err)
}
var dayRevRows []models.DayRevenueRow
if err := testDB.RevenueByDayLast30(&dayRevRows, time.Time{}); err != nil {
t.Fatalf("RevenueByDayLast30: %v", err)
}
sum := 0.0
for _, r := range dayRevRows {
sum += r.Revenue
}
if sum != totalRevenue {
t.Errorf("somme RevenueByDayLast30 = %.2f, doit correspondre à TotalRevenue = %.2f", sum, totalRevenue)
}
}
// DailyProductDetailForDate ne doit inclure que les commandes du jour demandé.
func TestDailyProductDetailForDate_OnlyIncludesGivenDate(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "stats_daily_date_filter")
productID := newTestProduct(t, "StatsDailyDateFilter", 100)
today := time.Now()
yesterday := today.AddDate(0, 0, -1)
newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, today)
newTestOrderForStats(t, username, "approved", productID, 1, 20, 0, yesterday)
var rows []models.DailyProductRow
if err := testDB.DailyProductDetailForDate(&rows, today); err != nil {
t.Fatalf("DailyProductDetailForDate: %v", err)
}
sum := 0.0
for _, r := range rows {
sum += r.Revenue
}
if sum != 10 {
t.Errorf("revenu du jour ne doit inclure que la commande d'aujourd'hui: got=%.2f want=10", sum)
}
}
+284
View File
@@ -0,0 +1,284 @@
package tests
import (
"sync"
"testing"
)
// ── Client (CancelCommandAtomic) ────────────────────────────────────────────
func TestCancelCommandAtomic_RefundsStockExactly(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_client_ok")
productID := newTestProduct(t, "CancelClientOk", 5)
// pending, sans livreur assigné -> annulation sans pénalité ni confirmation requise
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err != nil {
t.Fatalf("CancelCommandAtomic: %v", err)
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après annulation client (5 initial + 3 remboursés): got=%.2f want=8", got)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation: got=%s want=cancelled", got)
}
}
func TestCancelCommandAtomic_DoubleCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_client_dbl")
productID := newTestProduct(t, "CancelClientDbl", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err != nil {
t.Fatalf("1er CancelCommandAtomic: %v", err)
}
// Rejeu (double-tap / retry réseau) : doit échouer proprement, pas de second remboursement.
if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err == nil {
t.Fatal("le second appel sur une commande déjà annulée doit renvoyer une erreur")
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après double annulation (doit rester remboursé une seule fois): got=%.2f want=8", got)
}
}
func TestCancelCommandAtomic_ConcurrentCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_client_concurrent")
productID := newTestProduct(t, "CancelClientConcurrent", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30)
var wg sync.WaitGroup
results := make([]error, 3)
for i := range 3 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, results[idx] = testDB.CancelCommandAtomic(cmdID, username, "test", false)
}(i)
}
wg.Wait()
successCount := 0
for _, err := range results {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("un seul appel concurrent doit réussir l'annulation: got=%d succès", successCount)
}
if got := productStock(t, productID); got != 8 {
t.Errorf("stock après 3 annulations concurrentes de la même commande: got=%.2f want=8 (un seul remboursement)", got)
}
}
// ── Livreur (CancelDeliveryByLivreurAtomic) ─────────────────────────────────
func TestCancelDeliveryByLivreurAtomic_RefundsStockExactly(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_livreur_ok")
productID := newTestProduct(t, "CancelLivreurOk", 5)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurX", productID, 2, 20)
alreadyCancelled, prevStatus, err := testDB.CancelDeliveryByLivreurAtomic(cmdID)
if err != nil {
t.Fatalf("CancelDeliveryByLivreurAtomic: %v", err)
}
if alreadyCancelled {
t.Error("alreadyCancelled ne doit pas être true au premier appel")
}
if prevStatus != "en_route" {
t.Errorf("prevStatus: got=%s want=en_route", prevStatus)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après annulation livreur (5 initial + 2 remboursés): got=%.2f want=7", got)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation livreur: got=%s want=cancelled", got)
}
}
func TestCancelDeliveryByLivreurAtomic_DoubleCancelIsIdempotent(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_livreur_dbl")
productID := newTestProduct(t, "CancelLivreurDbl", 5)
cmdID := newTestCommandWithItem(t, username, "arrived", testUserPrefix+"livreurX", productID, 2, 20)
if _, _, err := testDB.CancelDeliveryByLivreurAtomic(cmdID); err != nil {
t.Fatalf("1er appel: %v", err)
}
alreadyCancelled, _, err := testDB.CancelDeliveryByLivreurAtomic(cmdID)
if err != nil {
t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err)
}
if !alreadyCancelled {
t.Error("le 2e appel doit renvoyer alreadyCancelled=true")
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après double annulation livreur (doit rester remboursé une seule fois): got=%.2f want=7", got)
}
}
func TestCancelDeliveryByLivreurAtomic_ConcurrentCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_livreur_concurrent")
productID := newTestProduct(t, "CancelLivreurConcurrent", 5)
cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurX", productID, 2, 20)
var wg sync.WaitGroup
n := 3
alreadyFlags := make([]bool, n)
errs := make([]error, n)
for i := range n {
wg.Add(1)
go func(idx int) {
defer wg.Done()
alreadyFlags[idx], _, errs[idx] = testDB.CancelDeliveryByLivreurAtomic(cmdID)
}(i)
}
wg.Wait()
freshCancelCount := 0
for i := range n {
if errs[i] != nil {
t.Errorf("appel %d: erreur inattendue: %v", i, errs[i])
continue
}
if !alreadyFlags[i] {
freshCancelCount++
}
}
if freshCancelCount != 1 {
t.Errorf("un seul appel concurrent doit effectuer l'annulation réelle: got=%d", freshCancelCount)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après annulations concurrentes livreur: got=%.2f want=7 (un seul remboursement)", got)
}
}
// ── Admin/Cabine (DeleteCommandAtomic) ──────────────────────────────────────
func TestDeleteCommandAtomic_RefundsStockAndRemovesCommand(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delete_admin_ok")
productID := newTestProduct(t, "DeleteAdminOk", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 4, 40)
if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil {
t.Fatalf("DeleteCommandAtomic: %v", err)
}
if got := productStock(t, productID); got != 9 {
t.Errorf("stock après suppression admin (5 initial + 4 remboursés): got=%.2f want=9", got)
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count)
if count != 0 {
t.Errorf("la commande doit être supprimée de la base: got=%d lignes restantes", count)
}
}
// Si la commande est déjà annulée ou approuvée, le stock a déjà été traité
// par le chemin correspondant — DeleteCommandAtomic ne doit pas rembourser
// une seconde fois lors d'une suppression a posteriori.
func TestDeleteCommandAtomic_DoesNotRefundAlreadyCancelledOrApprovedOrder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delete_admin_already")
productID := newTestProduct(t, "DeleteAdminAlready", 5)
cmdID := newTestCommandWithItem(t, username, "cancelled", "", productID, 4, 40)
if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil {
t.Fatalf("DeleteCommandAtomic: %v", err)
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas être remboursé pour une commande déjà annulée: got=%.2f want=5", got)
}
}
// Régression: une commande "livrée" (marchandise déjà sortie du stock au
// checkout, remise au client) ne doit pas voir son stock remboursé lors
// d'une suppression — symétrique à CancelCommandByAdminAtomic qui exclut
// déjà "livre" de son remboursement.
func TestDeleteCommandAtomic_DoesNotRefundDeliveredOrder(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delete_admin_livre")
productID := newTestProduct(t, "DeleteAdminLivre", 5)
cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 4, 40)
if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil {
t.Fatalf("DeleteCommandAtomic: %v", err)
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got)
}
}
func TestDeleteCommandAtomic_UnknownCommandReturnsError(t *testing.T) {
cleanupStockTestData(t)
err := testDB.DeleteCommandAtomic(99999999, "admin_test", "admin")
if err == nil {
t.Fatal("DeleteCommandAtomic sur une commande inexistante doit retourner une erreur")
}
}
// ── Crypto (CancelCryptoCommand) ────────────────────────────────────────────
func TestCancelCryptoCommand_RefundsStockOnPendingPayment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_crypto_ok")
productID := newTestProduct(t, "CancelCryptoOk", 5)
cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20)
if err := testDB.CancelCryptoCommand(cmdID); err != nil {
t.Fatalf("CancelCryptoCommand: %v", err)
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après annulation crypto (5 initial + 2 remboursés): got=%.2f want=7", got)
}
if got := commandStatus(t, cmdID); got != "cancelled" {
t.Errorf("statut après annulation crypto: got=%s want=cancelled", got)
}
}
func TestCancelCryptoCommand_RejectsIfNotPendingPayment(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_crypto_wrong")
productID := newTestProduct(t, "CancelCryptoWrong", 5)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
if err := testDB.CancelCryptoCommand(cmdID); err == nil {
t.Fatal("attendu une erreur : seule une commande pending_payment est annulable via ce chemin")
}
if got := productStock(t, productID); got != 5 {
t.Errorf("stock ne doit pas bouger si le statut n'est pas pending_payment: got=%.2f want=5", got)
}
}
func TestCancelCryptoCommand_DoubleCancelDoesNotDoubleRefund(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "cancel_crypto_dbl")
productID := newTestProduct(t, "CancelCryptoDbl", 5)
cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20)
if err := testDB.CancelCryptoCommand(cmdID); err != nil {
t.Fatalf("1er appel: %v", err)
}
if err := testDB.CancelCryptoCommand(cmdID); err == nil {
t.Fatal("le 2e appel (webhook rejoué) doit échouer, pas rembourser une seconde fois")
}
if got := productStock(t, productID); got != 7 {
t.Errorf("stock après webhook rejoué: got=%.2f want=7 (un seul remboursement)", got)
}
}
@@ -0,0 +1,200 @@
package tests
import (
"sync"
"testing"
)
// AddToBasket vérifie le stock disponible mais ne le décrémente jamais —
// le stock réel n'est consommé qu'au checkout (voir comprehension-metier).
func TestAddToBasket_RejectsInsufficientStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addbasket_insuff")
productID := newTestProduct(t, "AddBasketInsuff", 2)
_, err := testDB.AddToBasket(username, productID, 3)
if err == nil {
t.Fatal("attendu une erreur (stock insuffisant), reçu nil")
}
if got := productStock(t, productID); got != 2 {
t.Errorf("stock ne doit pas bouger sur un ajout panier refusé: got=%.2f want=2", got)
}
}
func TestAddToBasket_DoesNotDecrementStock(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "addbasket_ok")
productID := newTestProduct(t, "AddBasketOk", 10)
if _, err := testDB.AddToBasket(username, productID, 4); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
if got := productStock(t, productID); got != 10 {
t.Errorf("le stock ne doit être décrémenté qu'au checkout, pas à l'ajout panier: got=%.2f want=10", got)
}
}
// Le checkout (CreateCommandWithAddress) doit décrémenter le stock exactement
// de la quantité commandée, dans la même transaction que la création de la
// commande et le vidage du panier.
func TestCheckout_DecrementsStockExactly(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_ok")
productID := newTestProduct(t, "CheckoutOk", 10)
if _, err := testDB.AddToBasket(username, productID, 3); 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 != 7 {
t.Errorf("stock après checkout de 3 unités sur 10: got=%.2f want=7", got)
}
var basketCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount)
if basketCount != 0 {
t.Errorf("le panier doit être vidé après checkout, reste %d article(s)", basketCount)
}
var status string
testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, cmd.ID).Scan(&status)
if status != "pending" {
t.Errorf("statut de la commande créée: got=%s want=pending", status)
}
}
// Règle métier : les articles récompense (is_reward=true) sont des produits
// physiques réellement distribués et doivent décrémenter le stock exactement
// comme un article payant — jamais exclus du décompte.
func TestCheckout_RewardItemDecrementsStockLikeAPaidItem(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_reward")
paidProductID := newTestProduct(t, "CheckoutRewardPaid", 10)
rewardProductID := newTestProduct(t, "CheckoutRewardFree", 5)
if _, err := testDB.AddToBasket(username, paidProductID, 2); err != nil {
t.Fatalf("AddToBasket (payant): %v", err)
}
// Article récompense : inséré directement (produit par ClaimMyReward en
// production), prix affiché 0€, mais le stock doit être traité pareil.
if err := testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
VALUES (?, ?, 2, 0, true, 'pool_0', CURRENT_TIMESTAMP)`,
username, rewardProductID,
).Error; err != nil {
t.Fatalf("insertion article récompense: %v", err)
}
if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil {
t.Fatalf("CreateCommandWithAddress: %v", err)
}
if got := productStock(t, paidProductID); got != 8 {
t.Errorf("stock produit payant après checkout: got=%.2f want=8", got)
}
if got := productStock(t, rewardProductID); got != 3 {
t.Errorf("stock produit récompense après checkout (doit décrémenter comme un article payant): got=%.2f want=3", got)
}
}
// Un stock insuffisant sur un seul article du panier doit faire échouer tout
// le checkout, sans décrémenter partiellement les autres articles ni créer de
// commande fantôme (le tout est dans une seule transaction).
func TestCheckout_InsufficientStockOnOneItemRollsBackEverything(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_partial")
okProductID := newTestProduct(t, "CheckoutPartialOk", 10)
shortProductID := newTestProduct(t, "CheckoutPartialShort", 1)
if _, err := testDB.AddToBasket(username, okProductID, 5); err != nil {
t.Fatalf("AddToBasket (ok): %v", err)
}
// Second article : on force un panier dont la quantité dépasse le stock
// disponible au moment du checkout (simulation d'une désynchronisation,
// par ex. deux clients ayant chacun ajouté le dernier article en stock).
if err := testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`,
username, shortProductID,
).Error; err != nil {
t.Fatalf("insertion panier insuffisant: %v", err)
}
before := productStock(t, okProductID)
_, err := testDB.CreateCommandWithAddress(username, "1 rue de test")
if err == nil {
t.Fatal("attendu un échec de checkout (stock insuffisant), reçu nil")
}
if got := productStock(t, okProductID); got != before {
t.Errorf("le stock du 1er article ne doit pas être décrémenté si le 2e échoue (pas de décrément partiel): got=%.2f want=%.2f", got, before)
}
if got := productStock(t, shortProductID); got != 1 {
t.Errorf("stock du produit en rupture ne doit pas bouger: got=%.2f want=1", got)
}
var basketCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount)
if basketCount != 2 {
t.Errorf("le panier ne doit pas être vidé si le checkout échoue: got=%d want=2", basketCount)
}
var cmdCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount)
if cmdCount != 0 {
t.Errorf("aucune commande fantôme ne doit être créée si le checkout échoue: got=%d want=0", cmdCount)
}
}
// Deux checkouts quasi simultanés pour le même client (double-tap / retry
// réseau) sur un stock tout juste suffisant pour une seule commande ne
// doivent décrémenter le stock qu'une seule fois — le verrou FOR UPDATE sur
// le panier sérialise les deux tentatives.
func TestCheckout_ConcurrentDoubleSubmitDoesNotOversell(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_concurrent")
productID := newTestProduct(t, "CheckoutConcurrent", 2)
if _, err := testDB.AddToBasket(username, productID, 2); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
var wg sync.WaitGroup
results := make([]error, 2)
for i := range 2 {
wg.Add(1)
go func(idx int) {
defer wg.Done()
_, results[idx] = testDB.CreateCommandWithAddress(username, "1 rue de test")
}(i)
}
wg.Wait()
successCount := 0
for _, err := range results {
if err == nil {
successCount++
}
}
if successCount != 1 {
t.Errorf("exactement 1 des 2 checkouts concurrents doit réussir (panier vidé par le premier): got=%d succès", successCount)
}
if got := productStock(t, productID); got != 0 {
t.Errorf("stock final après un seul checkout réussi de 2 unités sur 2: got=%.2f want=0", got)
}
var cmdCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount)
if cmdCount != 1 {
t.Errorf("une seule commande doit avoir été créée: got=%d want=1", cmdCount)
}
}
@@ -0,0 +1,428 @@
package tests
import (
"bytes"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gestion/handlers"
"github.com/gin-gonic/gin"
)
// ── Helpers ──────────────────────────────────────────────────────────────
// newTestCategory crée une catégorie de test et programme son nettoyage.
// CreateProduct/UpdateProduct passent la catégorie reçue par strings.ToLower
// avant de vérifier son existence : le nom doit donc déjà être en
// minuscules ici pour matcher.
func newTestCategory(t *testing.T, name string) string {
t.Helper()
fullName := strings.ToLower(testProductPrefix + name)
if _, err := testDB.CreateCategory(fullName, "#000000", false); err != nil {
t.Fatalf("CreateCategory %q: %v", fullName, err)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM categories WHERE name = ?`, fullName)
})
return fullName
}
// productCreateRequest construit une requête multipart minimale pour
// CreateProduct (un seul prix, pas de média).
func productCreateRequest(fields map[string]string) (*bytes.Buffer, string) {
body := &bytes.Buffer{}
w := multipart.NewWriter(body)
for k, v := range fields {
w.WriteField(k, v)
}
w.Close()
return body, w.FormDataContentType()
}
func productContext(role string, body *bytes.Buffer, contentType string, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
if productID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
}
return c, rec
}
func jsonStockContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products/stock", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
if productID != 0 {
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
}
return c, rec
}
// ── CreateProduct ────────────────────────────────────────────────────────
func TestCreateProduct_RejectsNonAdminNonCabine(t *testing.T) {
cleanupStockTestData(t)
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CP1", "category": "x", "description": "d",
"stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("client", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role client doit être refusé: got=%d want=403", rec.Code)
}
}
func TestCreateProduct_RejectsNegativeStock(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatNeg")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPNeg", "category": cat, "description": "d",
"stock": "-5", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_RejectsStockOverMax(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatOver")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPOver", "category": cat, "description": "d",
"stock": "1000001", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_RejectsUnknownCategory(t *testing.T) {
cleanupStockTestData(t)
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPCat", "category": "categorie-inexistante-xyz", "description": "d",
"stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("catégorie inconnue doit être rejetée: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
}
func TestCreateProduct_SuccessCreatesProductAndPrice(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatOk")
body, ct := productCreateRequest(map[string]string{
"name": testProductPrefix + "CPOk", "category": cat, "description": "d",
"stock": "42", "prices[0][quantity]": "1", "prices[0][price]": "9.99",
})
c, rec := productContext("admin", body, ct, 0)
handlers.CreateProduct(c)
if rec.Code != http.StatusCreated {
t.Fatalf("création produit valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE name = ?`, testProductPrefix+"CPOk").Scan(&count)
if count != 1 {
t.Errorf("le produit doit être créé en base: got=%d", count)
}
var priceCount int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM product_prices pp JOIN products p ON p.id = pp.product_id WHERE p.name = ?`, testProductPrefix+"CPOk").Scan(&priceCount)
if priceCount != 1 {
t.Errorf("le prix doit être créé en base: got=%d", priceCount)
}
t.Cleanup(func() {
testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name = ?)`, testProductPrefix+"CPOk")
testDB.GDB.Exec(`DELETE FROM products WHERE name = ?`, testProductPrefix+"CPOk")
})
}
// ── UpdateStock ──────────────────────────────────────────────────────────
func TestUpdateStock_RejectsNonAdmin(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USNonAdmin", 10)
c, rec := jsonStockContext("cabine", []byte(`{"stock":20}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusForbidden {
t.Errorf("role cabine doit être refusé pour UpdateStock: got=%d want=403", rec.Code)
}
}
func TestUpdateStock_RejectsNegativeStock(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USNeg", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":-1}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 10 {
t.Errorf("le stock ne doit pas changer après un rejet: got=%.2f want=10", got)
}
}
func TestUpdateStock_RejectsStockOverMax(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USOver", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":1000001}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusBadRequest {
t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400", rec.Code)
}
}
// Documente un bug existant plutôt que le comportement souhaité :
// GetProductByID fait un Raw(...).Scan() qui ne retourne PAS d'erreur
// quand aucune ligne ne matche (contrairement à First()), donc le check
// "produit non trouvé → 404" en tête de UpdateStock ne se déclenche
// jamais. La requête continue jusqu'à SetProductStock, qui échoue côté
// DB et remonte en 500 générique au lieu d'un 404 propre.
func TestUpdateStock_UnknownProductReturns500NotFoundBugDocumented(t *testing.T) {
cleanupStockTestData(t)
c, rec := jsonStockContext("admin", []byte(`{"stock":5}`), 99999999)
handlers.UpdateStock(c)
if rec.Code != http.StatusInternalServerError {
t.Errorf("comportement actuel (bug): produit inconnu retourne 500, pas 404: got=%d", rec.Code)
}
}
func TestUpdateStock_SuccessSetsExactValue(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "USOk", 10)
c, rec := jsonStockContext("admin", []byte(`{"stock":33}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusOK {
t.Fatalf("mise à jour stock valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 33 {
t.Errorf("stock après UpdateStock: got=%.2f want=33", got)
}
}
// Documente le comportement actuel du garde-fou "réservé en panier" de
// UpdateStock : `req.Stock+reserved < reserved` est algébriquement
// équivalent à `req.Stock < 0`, donc ce garde-fou ne bloque en réalité
// jamais un stock positif inférieur à la quantité réservée. Un admin peut
// donc, aujourd'hui, mettre le stock en dessous du réservé en panier — ce
// test documente ce comportement pour éviter une régression silencieuse
// si quelqu'un "corrige" la formule sans le vouloir explicitement.
func TestUpdateStock_ReservedGuardDoesNotBlockStockBelowReserved(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "us_reserved")
productID := newTestProduct(t, "USReserved", 10)
if _, err := testDB.AddToBasket(username, productID, 8); err != nil {
t.Fatalf("AddToBasket: %v", err)
}
// 8 sont réservés en panier ; on met le stock à 1, en dessous du réservé.
c, rec := jsonStockContext("admin", []byte(`{"stock":1}`), productID)
handlers.UpdateStock(c)
if rec.Code != http.StatusOK {
t.Fatalf("comportement actuel: la requête réussit malgré stock < réservé: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 1 {
t.Errorf("stock après UpdateStock: got=%.2f want=1", got)
}
}
// ── UpdateProduct ────────────────────────────────────────────────────────
func updateProductJSONContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set("database", testDB)
c.Set("username", testUserPrefix+"product_admin")
c.Set("role", role)
c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}}
return c, rec
}
func TestUpdateProduct_UpdatesStockWhenProvided(t *testing.T) {
cleanupStockTestData(t)
cat := newTestCategory(t, "CatUpd")
productID := newTestProduct(t, "UPStock", 5)
body := []byte(fmt.Sprintf(`{"name":"UPStock","category":%q,"description":"d2","unit":"kg","stock":77,"prices":[{"quantity":1,"price":5}]}`, cat))
c, rec := updateProductJSONContext("admin", body, productID)
handlers.UpdateProduct(c)
if rec.Code != http.StatusOK {
t.Fatalf("UpdateProduct valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
if got := productStock(t, productID); got != 77 {
t.Errorf("stock après UpdateProduct: got=%.2f want=77", got)
}
}
// ── DeleteProduct ────────────────────────────────────────────────────────
func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "DelNoMedia", 3)
c, rec := productContext("admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", productID)
handlers.DeleteProduct(c)
if rec.Code != http.StatusOK {
t.Fatalf("suppression produit sans média doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE id = ?`, productID).Scan(&count)
if count != 0 {
t.Errorf("le produit doit être supprimé: got=%d lignes restantes", count)
}
}
// ── GetReservedQuantityInBaskets ─────────────────────────────────────────
func TestGetReservedQuantityInBaskets_SumsNonRewardQuantities(t *testing.T) {
cleanupStockTestData(t)
productID := newTestProduct(t, "ResQty", 20)
c1 := newTestClient(t, "resqty_c1")
c2 := newTestClient(t, "resqty_c2")
if _, err := testDB.AddToBasket(c1, productID, 3); err != nil {
t.Fatalf("AddToBasket c1: %v", err)
}
if _, err := testDB.AddToBasket(c2, productID, 5); err != nil {
t.Fatalf("AddToBasket c2: %v", err)
}
reserved, err := testDB.GetReservedQuantityInBaskets(productID)
if err != nil {
t.Fatalf("GetReservedQuantityInBaskets: %v", err)
}
if reserved != 8 {
t.Errorf("réservé total: got=%.2f want=8", reserved)
}
}
// ── AddToBasket merge behavior ───────────────────────────────────────────
func TestAddToBasket_MergesIntoExistingNonRewardRow(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "merge_basket")
productID := newTestProduct(t, "MergeBasket", 20)
if _, err := testDB.AddToBasket(username, productID, 2); err != nil {
t.Fatalf("AddToBasket #1: %v", err)
}
if _, err := testDB.AddToBasket(username, productID, 3); err != nil {
t.Fatalf("AddToBasket #2: %v", err)
}
var count int64
testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&count)
if count != 1 {
t.Fatalf("les deux ajouts doivent fusionner en une seule ligne: got=%d lignes", count)
}
var qty float64
testDB.GDB.Raw(`SELECT quantity FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&qty)
if qty != 5 {
t.Errorf("quantité fusionnée: got=%.2f want=5", qty)
}
}
// ── Checkout validation (db.CreateCommandWithAddress) ───────────────────
func TestCreateCommandWithAddress_RejectsEmptyBasket(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_empty")
_, err := testDB.CreateCommandWithAddress(username, "1 rue vide")
if err == nil {
t.Fatal("checkout avec panier vide doit échouer")
}
}
func TestCreateCommandWithAddress_RejectsInvalidBasketItemData(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_invalid_item")
productID := newTestProduct(t, "CheckoutInvalidItem", 10)
// Insertion directe d'une ligne de panier avec quantité invalide,
// en contournant AddToBasket qui la rejetterait.
testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`,
username, productID, 0, 10,
)
_, err := testDB.CreateCommandWithAddress(username, "1 rue invalide")
if err == nil {
t.Fatal("checkout avec donnée panier invalide (quantité=0) doit échouer")
}
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
}
func TestCreateCommandWithAddress_RejectsTotalPriceOverMax(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "checkout_overmax")
productID := newTestProduct(t, "CheckoutOverMax", 10)
testDB.GDB.Exec(
`INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`,
username, productID, 1, 100001,
)
_, err := testDB.CreateCommandWithAddress(username, "1 rue trop cher")
if err == nil {
t.Fatal("checkout avec montant total > 100000 doit échouer")
}
testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username)
}
// ── DeleteCommandItem ─────────────────────────────────────────────────────
func TestDeleteCommandItem_UpdatesCommandTotalPrix(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_total")
productID := newTestProduct(t, "DelItemTotal", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
testDB.GDB.Exec(`UPDATE commandes SET total_prix = 20 WHERE id = ?`, cmdID)
var itemID int
testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ?`, cmdID).Scan(&itemID)
if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil {
t.Fatalf("DeleteCommandItem: %v", err)
}
var totalPrix float64
testDB.GDB.Raw(`SELECT total_prix FROM commandes WHERE id = ?`, cmdID).Scan(&totalPrix)
if totalPrix != 0 {
t.Errorf("total_prix après suppression du seul item: got=%.2f want=0", totalPrix)
}
}
func TestDeleteCommandItem_UnknownItemReturnsError(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "delitem_unknown")
productID := newTestProduct(t, "DelItemUnknown", 10)
cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20)
if err := testDB.DeleteCommandItem(cmdID, 99999999); err == nil {
t.Fatal("suppression d'un item inconnu doit retourner une erreur")
}
}
func TestDeleteCommandItem_UnknownCommandReturnsError(t *testing.T) {
cleanupStockTestData(t)
if err := testDB.DeleteCommandItem(99999999, 1); err == nil {
t.Fatal("suppression d'un item sur une commande inconnue doit retourner une erreur")
}
}

Some files were not shown because too many files have changed in this diff Show More