chore: build
This commit is contained in:
@@ -3,19 +3,35 @@ package db
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (d *Database) CheckAddress(addressByUser *models.Command) error {
|
||||
var correction models.Address
|
||||
result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction)
|
||||
if result.Error != nil {
|
||||
if isNotFound(result.Error) {
|
||||
return nil
|
||||
}
|
||||
if result.Error == nil {
|
||||
addressByUser.DeliveryAddress = correction.CorrectAddress
|
||||
return fmt.Errorf("adresse invalide %s", correction.CorrectAddress)
|
||||
}
|
||||
if !isNotFound(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 {
|
||||
|
||||
@@ -27,7 +27,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
|
||||
|
||||
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
|
||||
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 alerts, nil
|
||||
@@ -65,7 +65,7 @@ func (d *Database) ActivateAlert(id int) error {
|
||||
|
||||
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
||||
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 alerts, nil
|
||||
@@ -73,7 +73,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
||||
|
||||
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
|
||||
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 alerts, nil
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"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) {
|
||||
var result struct {
|
||||
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) {
|
||||
var baskets []models.Panier
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
// Supprimer tout article récompense existant (remplacement)
|
||||
tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username)
|
||||
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 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
|
||||
var err error
|
||||
baskets, err = addRewardsToBasketTx(tx, username, items, poolKey)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -77,6 +58,36 @@ func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem
|
||||
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.
|
||||
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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(¤tStock).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) {
|
||||
var username string
|
||||
err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error
|
||||
|
||||
@@ -78,9 +78,14 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
|
||||
|
||||
if err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
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)
|
||||
}
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
@@ -145,20 +150,18 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
|
||||
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
|
||||
if err != nil {
|
||||
etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result()
|
||||
if err != nil || len(etaData) == 0 {
|
||||
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
var etaMinutes int
|
||||
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes)
|
||||
if err != nil || etaMinutes <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
|
||||
if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"])
|
||||
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)
|
||||
|
||||
// ✅ 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 err := tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil {
|
||||
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 {
|
||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
|
||||
@@ -316,12 +324,89 @@ func (d *Database) AddClientPenalty(username string, points int) error {
|
||||
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 tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error
|
||||
var prevStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ type Category struct {
|
||||
Name string `json:"name" gorm:"column:name"`
|
||||
Color string `json:"color" gorm:"column:color"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -30,7 +31,7 @@ func ValidateCategoryColor(color string) error {
|
||||
|
||||
func (d *Database) GetAllCategories() ([]Category, error) {
|
||||
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
|
||||
}
|
||||
if categories == nil {
|
||||
@@ -43,7 +44,9 @@ func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Categ
|
||||
if color == "" {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -82,6 +85,18 @@ func (d *Database) DeleteCategory(id int) error {
|
||||
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) {
|
||||
var count int64
|
||||
err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error
|
||||
|
||||
@@ -32,6 +32,7 @@ func (d *Database) CreateClient(client *models.Client) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientByID récupère un client par son ID
|
||||
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
@@ -75,6 +76,7 @@ func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetAllClients récupère tous les clients
|
||||
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
||||
var rows []struct {
|
||||
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
|
||||
// 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) {
|
||||
var row struct {
|
||||
ID int `gorm:"column:id"`
|
||||
@@ -713,52 +737,81 @@ func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[s
|
||||
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.
|
||||
// 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) {
|
||||
var points, redeemed int
|
||||
|
||||
err = d.GDB.Transaction(func(tx *gorm.DB) 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 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
|
||||
var err error
|
||||
remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
earned := points / threshold
|
||||
remainingAvailable = earned - (redeemed + 1)
|
||||
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).
|
||||
func (d *Database) ResetClientRedeemed(username, poolKey string) error {
|
||||
if poolKey != "" {
|
||||
|
||||
@@ -6,8 +6,37 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"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
|
||||
// ============================================
|
||||
@@ -308,6 +337,92 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) {
|
||||
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
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
@@ -316,6 +431,12 @@ func ptrStr(s *string) string {
|
||||
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 {
|
||||
log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID)
|
||||
|
||||
@@ -326,61 +447,55 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Prix float64 `gorm:"column:prix"`
|
||||
Quantite float64 `gorm:"column:quantite"`
|
||||
ProductID int `gorm:"column:product_id"`
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
return d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var cmdStatus string
|
||||
if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil {
|
||||
return fmt.Errorf("erreur vérification commande: %w", err)
|
||||
}
|
||||
if cmdStatus == "" {
|
||||
return fmt.Errorf("commande %d non trouvée", commandID)
|
||||
}
|
||||
log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return fmt.Errorf("erreur commit transaction: %w", err)
|
||||
}
|
||||
var result struct {
|
||||
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 {
|
||||
|
||||
+144
-158
@@ -1,6 +1,8 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
@@ -11,6 +13,9 @@ import (
|
||||
"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 {
|
||||
sanitized := strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
@@ -52,18 +57,6 @@ type basketItem struct {
|
||||
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
|
||||
func validateCommandStatus(status string) error {
|
||||
validStatuses := map[string]bool{
|
||||
@@ -84,74 +77,6 @@ func validateCommandStatus(status string) error {
|
||||
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) {
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, err
|
||||
@@ -175,92 +100,122 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
||||
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(¤tStock).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 {
|
||||
log.Printf("❌ Erreur query basket: %v", err)
|
||||
log.Printf("❌ Erreur création commande: %v", 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)
|
||||
d.AddCommandLog(commandID, "created",
|
||||
d.AddCommandLog(command.ID, "created",
|
||||
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
|
||||
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
|
||||
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
|
||||
}
|
||||
|
||||
@@ -424,9 +379,28 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
||||
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.
|
||||
// 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) {
|
||||
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 {
|
||||
DestLatitude float64 `gorm:"column:dest_latitude"`
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -772,6 +750,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s
|
||||
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" {
|
||||
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", 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 errors.Is(err, errAlreadyApproved) {
|
||||
return 0, "", nil
|
||||
}
|
||||
return 0, "", err
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at
|
||||
FROM commandes
|
||||
|
||||
@@ -25,20 +25,16 @@ func wazeAppLink(lat, lon float64) string {
|
||||
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 {
|
||||
return MapLinks{
|
||||
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 {
|
||||
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) {
|
||||
_, _, err := d.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
|
||||
@@ -53,7 +53,7 @@ func InitDB() *Database {
|
||||
// Configuration du pool de connexions
|
||||
db.SetMaxOpenConns(50)
|
||||
db.SetMaxIdleConns(10)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
db.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
// Tester la connexion
|
||||
if err = db.Ping(); err != nil {
|
||||
@@ -66,7 +66,9 @@ func InitDB() *Database {
|
||||
gormDB, err := gorm.Open(postgres.New(postgres.Config{
|
||||
Conn: db,
|
||||
}), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
SkipDefaultTransaction: true,
|
||||
PrepareStmt: true,
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur initialisation GORM: %v", err)
|
||||
@@ -165,6 +167,23 @@ func InitDB() *Database {
|
||||
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
|
||||
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
@@ -277,6 +296,21 @@ func InitDB() *Database {
|
||||
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
|
||||
go database.cleanExpiredTokensPeriodically()
|
||||
|
||||
@@ -321,6 +355,7 @@ func (db *Database) createTables() error {
|
||||
cancellations_count INTEGER DEFAULT 0 NOT NULL,
|
||||
last_penalty_reason TEXT DEFAULT NULL,
|
||||
referral_balance NUMERIC(10,2) DEFAULT 0.0,
|
||||
parrain VARCHAR(255) DEFAULT NULL,
|
||||
updated_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,
|
||||
url TEXT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
key TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
@@ -513,6 +549,30 @@ func (db *Database) createTables() error {
|
||||
id SERIAL PRIMARY KEY,
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -59,8 +59,8 @@ func validateMediaURL(url string) error {
|
||||
if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") {
|
||||
return fmt.Errorf("path traversal détecté dans l'URL")
|
||||
}
|
||||
if !strings.HasPrefix(url, "/uploads/") {
|
||||
return fmt.Errorf("URL doit commencer par /uploads/")
|
||||
if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") {
|
||||
return fmt.Errorf("URL doit commencer par /uploads/ ou /media/")
|
||||
}
|
||||
dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"}
|
||||
for _, char := range dangerousChars {
|
||||
@@ -93,6 +93,11 @@ func (d *Database) CreateMedia(media any) error {
|
||||
mediaType := m.GetType()
|
||||
mediaURL := m.GetURL()
|
||||
|
||||
mediaKey := ""
|
||||
if mediaPtr, isPtr := media.(*models.Media); isPtr {
|
||||
mediaKey = mediaPtr.Key
|
||||
}
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
@@ -106,14 +111,14 @@ func (d *Database) CreateMedia(media any) error {
|
||||
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)
|
||||
return err
|
||||
}
|
||||
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
|
||||
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)
|
||||
@@ -128,9 +133,9 @@ func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType str
|
||||
ID int `gorm:"column:id"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
INSERT INTO media (product_id, url, type, created_at)
|
||||
VALUES (?, ?, ?, ?) RETURNING id`,
|
||||
productID, mediaURL, mediaType, time.Now(),
|
||||
INSERT INTO media (product_id, url, type, key, created_at)
|
||||
VALUES (?, ?, ?, ?, ?) RETURNING id`,
|
||||
productID, mediaURL, mediaType, key, time.Now(),
|
||||
).Scan(&result).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
|
||||
@@ -154,7 +159,7 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
||||
|
||||
var media models.Media
|
||||
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
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
|
||||
@@ -169,6 +174,20 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
||||
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) {
|
||||
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
@@ -179,7 +198,7 @@ func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
||||
|
||||
var mediaList []models.Media
|
||||
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 = ?
|
||||
ORDER BY id ASC`, productID).Scan(&mediaList).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
pipe.Exec(RedisCtx) //nolint
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
@@ -54,7 +54,6 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyLivreur envoie une notification in-app (Redis) à un livreur
|
||||
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
@@ -70,7 +69,7 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
|
||||
pipe2 := Redis.Pipeline()
|
||||
pipe2.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe2.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe2.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
pipe2.Expire(RedisCtx, notifKey, time.Hour)
|
||||
pipe2.Exec(RedisCtx) //nolint
|
||||
|
||||
if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() {
|
||||
@@ -88,7 +87,6 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
|
||||
return nil
|
||||
}
|
||||
|
||||
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
|
||||
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
@@ -114,7 +112,7 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA
|
||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
// NotifyAllAdminCabineAlert envoie une notification Redis à tous les admins/cabines lors d'une alerte
|
||||
func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) {
|
||||
var users []struct {
|
||||
Username string `gorm:"column:username"`
|
||||
@@ -156,7 +153,7 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert
|
||||
notifKey := fmt.Sprintf("notifications:%s", u.Username)
|
||||
pipe.LPush(RedisCtx, notifKey, notifJSON)
|
||||
pipe.LTrim(RedisCtx, notifKey, 0, 199)
|
||||
pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
pipe.Expire(RedisCtx, notifKey, time.Hour)
|
||||
}
|
||||
pipe.Exec(RedisCtx) //nolint
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error {
|
||||
@@ -18,13 +21,44 @@ func (d *Database) SetClientParrain(clientUsername, parrainUsername string) erro
|
||||
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) {
|
||||
var parrain string
|
||||
var parrain sql.NullString
|
||||
err := d.GDB.Table("clients").
|
||||
Select("parrain").
|
||||
Where("username = ?", clientUsername).
|
||||
Scan(&parrain).Error
|
||||
return parrain, err
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return parrain.String, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) {
|
||||
|
||||
@@ -73,14 +73,21 @@ func (d *Database) CreateProduct(product any) error {
|
||||
p.SetCreatedAt(result.CreatedAt)
|
||||
p.SetUpdatedAt(result.UpdatedAt)
|
||||
|
||||
for i, price := range p.GetPrices() {
|
||||
err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
|
||||
result.ID, price.Quantity, price.Price, price.ActivePrice).Error
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
|
||||
if rawPrices := p.GetPrices(); len(rawPrices) > 0 {
|
||||
priceRows := make([]models.ProductPrice, len(rawPrices))
|
||||
for i, price := range rawPrices {
|
||||
priceRows[i] = models.ProductPrice{
|
||||
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)
|
||||
}
|
||||
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)
|
||||
@@ -136,6 +143,27 @@ func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) {
|
||||
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) {
|
||||
log.Println("📦 [GetAllProducts] START")
|
||||
|
||||
@@ -149,23 +177,22 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
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 {
|
||||
prices, err := d.GetProductPrices(products[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", products[i].ID, err)
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
if prices, ok := allPrices[products[i].ID]; ok {
|
||||
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 {
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
}
|
||||
if media, ok := allMedia[products[i].ID]; ok {
|
||||
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)
|
||||
}
|
||||
|
||||
catProductIDs := make([]int, len(products))
|
||||
for i, p := range products {
|
||||
catProductIDs[i] = p.ID
|
||||
}
|
||||
catPrices := d.GetProductPricesBatch(catProductIDs)
|
||||
for i := range products {
|
||||
prices, err := d.GetProductPrices(products[i].ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", products[i].ID, err)
|
||||
products[i].Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
if prices, ok := catPrices[products[i].ID]; ok {
|
||||
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)
|
||||
|
||||
for _, price := range prices {
|
||||
if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`,
|
||||
productID, price.Quantity, price.Price, price.ActivePrice).Error; err != nil {
|
||||
if len(prices) > 0 {
|
||||
priceRows := make([]models.ProductPrice, len(prices))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,20 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error
|
||||
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 {
|
||||
result := d.GDB.Model(&models.ProductPrice{}).
|
||||
Where("id = ?", priceID).
|
||||
|
||||
@@ -189,7 +189,7 @@ func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
|
||||
@@ -27,25 +27,6 @@ func (d *Database) GetClientCancellationsCount(username string) (int, error) {
|
||||
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é
|
||||
func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
||||
if len(tiers) == 0 {
|
||||
@@ -64,6 +45,16 @@ func penaltyForCount(count int, tiers []models.PenaltyTier) int {
|
||||
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é
|
||||
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
@@ -71,13 +62,7 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
settings, err := d.GetSettings()
|
||||
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)
|
||||
penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty"))
|
||||
|
||||
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
|
||||
username, count, penalty)
|
||||
@@ -85,30 +70,48 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
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) {
|
||||
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 {
|
||||
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)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
|
||||
@@ -72,6 +72,18 @@ func DefaultSettings() models.AppSettings {
|
||||
Mode: "single",
|
||||
CategoryRoutes: []models.CategoryRoute{},
|
||||
},
|
||||
AdminColorPrimary: "#7c3aed",
|
||||
AdminColorSecondary: "#000000",
|
||||
AdminColorSuccess: "#4ade80",
|
||||
AdminColorDanger: "#ef4444",
|
||||
AdminColorWarning: "#f59e0b",
|
||||
ClientColorPrimary: "#7c3aed",
|
||||
ClientColorSecondary: "#000000",
|
||||
ClientColorSuccess: "#4ade80",
|
||||
ClientColorDanger: "#ef4444",
|
||||
ClientColorWarning: "#f59e0b",
|
||||
ClientTitleGradientFrom: "#a78bfa",
|
||||
ClientTitleGradientTo: "#22d3ee",
|
||||
DeliverySchedule: DefaultDeliverySchedule(),
|
||||
PostalZones: []models.PostalZone{
|
||||
{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"
|
||||
case "shop_name":
|
||||
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
|
||||
@@ -253,6 +289,18 @@ func (d *Database) UpdateSettings(s models.AppSettings) error {
|
||||
{"delivery_mode", string(deliveryModeJSON)},
|
||||
{"shop_name", s.ShopName},
|
||||
{"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 (?, ?)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func GenerateLinkToken(username, role string) (string, error) {
|
||||
|
||||
key := fmt.Sprintf("telegram:link:%s", token)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ func (d *Database) CreateUser(user *models.User) error {
|
||||
|
||||
func (d *Database) GetAllUsers() ([]*models.User, error) {
|
||||
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 users, nil
|
||||
@@ -23,7 +23,7 @@ func (d *Database) GetAllUsers() ([]*models.User, error) {
|
||||
|
||||
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
|
||||
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 users, nil
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
|
||||
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 {
|
||||
return "", fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i
|
||||
eta := map[string]any{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": totalETA,
|
||||
"eta_minutes": totalETA,
|
||||
"queue_position": queuePosition,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
|
||||
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
||||
|
||||
@@ -82,7 +81,6 @@ func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||
return removedCount, nil
|
||||
}
|
||||
|
||||
// removeInvalidCommand supprime une commande invalide de toutes les queues
|
||||
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) {
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
// GetQueueStats - Statistiques détaillées
|
||||
func (d *Database) GetQueueStats() (map[string]any, error) {
|
||||
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
||||
|
||||
@@ -142,7 +142,6 @@ func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
|
||||
func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
@@ -321,7 +320,6 @@ func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) er
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanRedisKeys remplace KEYS * par SCAN pour ne pas bloquer Redis.
|
||||
func scanRedisKeys(pattern string) ([]string, error) {
|
||||
var all []string
|
||||
cursor := uint64(0)
|
||||
|
||||
@@ -19,6 +19,24 @@ 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/loader v0.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
|
||||
@@ -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/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
|
||||
@@ -27,7 +27,6 @@ func AlertPolice(c *gin.Context) {
|
||||
var req struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
// message optionnel — on ignore l'erreur de bind
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
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"})
|
||||
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 {
|
||||
utils.ServerErr(c, "Impossible de supprimer l'alerte", err)
|
||||
return
|
||||
@@ -92,6 +110,15 @@ func GetAlert(c *gin.Context) {
|
||||
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{
|
||||
"success": true,
|
||||
"alert": alert,
|
||||
|
||||
@@ -55,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) {
|
||||
claims := models.AdminClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role, // ← "admin" ou "cabine" ou "livreur"
|
||||
Role: user.Role,
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)),
|
||||
IssuedAt: 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),
|
||||
},
|
||||
}
|
||||
@@ -73,112 +73,6 @@ func generateAdminToken(user *models.User) (string, error) {
|
||||
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)
|
||||
func AdminCreateClient(c *gin.Context) {
|
||||
if userRole := c.GetString("role"); userRole != "admin" {
|
||||
@@ -561,6 +455,12 @@ func LoginAdmin(c *gin.Context) {
|
||||
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)
|
||||
|
||||
expiresAt := time.Now().Add(adminTokenDuration)
|
||||
@@ -602,62 +502,6 @@ func LogoutAdmin(c *gin.Context) {
|
||||
// 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)
|
||||
func GetAllUsers(c *gin.Context) {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// ✅ AUTRES ERREURS
|
||||
switch err.Error() {
|
||||
case "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
|
||||
}
|
||||
|
||||
// ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles)
|
||||
var enrichedOrders []map[string]any
|
||||
for _, order := range cancelledOrders {
|
||||
orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"]))
|
||||
|
||||
@@ -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) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ func buildTimeline(logs []map[string]any) []gin.H {
|
||||
for _, logEntry := range logs {
|
||||
status, _ := logEntry["status"].(string)
|
||||
message, _ := logEntry["message"].(string)
|
||||
createdAt, _ := logEntry["created_at"]
|
||||
createdAt := logEntry["created_at"]
|
||||
|
||||
timeline = append(timeline, gin.H{
|
||||
"status": status,
|
||||
|
||||
@@ -482,10 +482,6 @@ func StaffApproveDelivery(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPROBATION PAR ADMIN
|
||||
// ============================================
|
||||
|
||||
func ValidateDelivery(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -1145,19 +1141,11 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
|
||||
}
|
||||
|
||||
if req.Status == "cancelled" {
|
||||
current, errCmd := database.GetCommandByID(commandID)
|
||||
if errCmd == nil {
|
||||
currentStatus, _ := current["status"].(string)
|
||||
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)
|
||||
}
|
||||
}
|
||||
if err := database.CancelCommandByAdminAtomic(commandID); err != nil {
|
||||
utils.ServerErr(c, "Impossible d'annuler la commande", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
} else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
utils.ServerErr(c, "Impossible de mettre à jour le statut", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,11 +12,12 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// IPNWebhook - POST /api/v1/webhook/nowpayments
|
||||
// IPNWebhook - POST /api/v1/webhooks/nowpayments
|
||||
func IPNWebhook(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
if !ok || np == nil {
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, ok := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !ok || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -40,14 +40,27 @@ func GetMyDeliveries(c *gin.Context) {
|
||||
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))
|
||||
for i, cmd := range commands {
|
||||
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)
|
||||
client, _ := database.GetClientByUsername(clientUsername)
|
||||
client := allClients[clientUsername]
|
||||
|
||||
clientInfo := gin.H{"nom": "Client", "prenom": ""}
|
||||
if client != nil {
|
||||
@@ -244,7 +257,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
|
||||
log.Printf("📍 [GPS] Distance: %.2f m", distance)
|
||||
|
||||
if distance > 100 {
|
||||
if distance > 350 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Vous êtes trop loin de la destination",
|
||||
"current_distance": fmt.Sprintf("%.2f", distance),
|
||||
@@ -259,22 +272,34 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le statut
|
||||
if err := database.UpdateCommandStatus(commandID, req.Status); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "Erreur mise à jour",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Mettre à jour le statut.
|
||||
// Le cas "cancelled" passe par une transaction atomique dédiée (transition +
|
||||
// remboursement stock), pour empêcher tout double remboursement en cas de
|
||||
// double appel (double-tap, retry réseau, commande déjà annulée ailleurs).
|
||||
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
|
||||
if cancelMsg == "" {
|
||||
cancelMsg = "Annulé par le livreur"
|
||||
}
|
||||
database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg))
|
||||
|
||||
prevStatus, _ := command["status"].(string)
|
||||
if prevStatus == "arrived" || prevStatus == "livre" {
|
||||
clientUsername, _ := command["username"].(string)
|
||||
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
|
||||
@@ -441,12 +471,8 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
|
||||
|
||||
case "cancelled":
|
||||
// Transition + remboursement stock déjà effectués atomiquement plus haut.
|
||||
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)
|
||||
|
||||
case "arrived":
|
||||
@@ -526,10 +552,8 @@ func ReportDeliveryIssue(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue})
|
||||
}
|
||||
|
||||
// GET /api/v1/livreur/stats
|
||||
func GetMyDeliveryStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
username, exists := c.Get("username")
|
||||
if !exists {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
@@ -540,66 +564,30 @@ func GetMyDeliveryStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
usernameStr := username.(string)
|
||||
gdb := database.GDB
|
||||
|
||||
type DayRow 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"`
|
||||
var dayRows []models.DayRowWithResult
|
||||
if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"})
|
||||
return
|
||||
}
|
||||
|
||||
var dayRows []DayRow
|
||||
gdb.Raw(`
|
||||
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
|
||||
`, usernameStr).Scan(&dayRows)
|
||||
var weekRows []models.WeekRow
|
||||
if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"})
|
||||
return
|
||||
}
|
||||
|
||||
var weekRows []WeekRow
|
||||
gdb.Raw(`
|
||||
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
|
||||
`, usernameStr).Scan(&weekRows)
|
||||
var monthRows []models.MonthRow
|
||||
if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"})
|
||||
return
|
||||
}
|
||||
|
||||
var monthRows []MonthRow
|
||||
gdb.Raw(`
|
||||
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
|
||||
`, usernameStr).Scan(&monthRows)
|
||||
var todayRow models.TodayRow
|
||||
if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"})
|
||||
return
|
||||
}
|
||||
|
||||
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{
|
||||
"success": true,
|
||||
"by_day": byDay,
|
||||
"by_week": byWeek,
|
||||
"by_month": byMonth,
|
||||
"success": true,
|
||||
"by_day": byDay,
|
||||
"by_week": byWeek,
|
||||
"by_month": byMonth,
|
||||
"today_count": todayRow.Count,
|
||||
"today_revenue": todayRow.Revenue,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
func GetDeliveryPersonDetails(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ: Admin seulement
|
||||
userRole := c.GetString("role")
|
||||
if userRole != "admin" && userRole != "cabine" && userRole != "livreur" {
|
||||
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
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
var locationInfo map[string]interface{}
|
||||
var locationInfo map[string]any
|
||||
if err == nil {
|
||||
locationInfo = map[string]interface{}{
|
||||
locationInfo = map[string]any{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4️⃣ VÉRIFIER LES DROITS D'ACCÈS
|
||||
cmdUsername, _ := command["username"].(string)
|
||||
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)
|
||||
|
||||
// ✅ CORRECTION: Vérifier si commande terminée
|
||||
if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" {
|
||||
log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -129,7 +126,6 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Pour pending/assigned: pas encore de position livreur disponible
|
||||
if cmdStatus == "pending" || cmdStatus == "assigned" {
|
||||
log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -142,7 +138,6 @@ func GetOrderETA(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Pour arrived: livreur sur place, ETA non pertinent
|
||||
if cmdStatus == "arrived" {
|
||||
log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -154,9 +149,7 @@ func GetOrderETA(c *gin.Context) {
|
||||
})
|
||||
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)
|
||||
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...")
|
||||
|
||||
// Récupérer coordonnées destination
|
||||
var destLat, destLon float64
|
||||
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
@@ -248,13 +239,10 @@ func GetOrderETA(c *gin.Context) {
|
||||
|
||||
toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon}
|
||||
|
||||
// Cas 1 : GPS livreur disponible
|
||||
livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign)
|
||||
if gpsErr != nil {
|
||||
// Cas 2 : GPS absent → dernière adresse de livraison
|
||||
lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign)
|
||||
if lastErr != nil || lastLat == 0 {
|
||||
// Cas 3 : Aucune position → cache périmé ou message
|
||||
log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign)
|
||||
c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData))
|
||||
return
|
||||
@@ -263,7 +251,6 @@ func GetOrderETA(c *gin.Context) {
|
||||
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)",
|
||||
livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude)
|
||||
|
||||
@@ -274,11 +261,10 @@ func GetOrderETA(c *gin.Context) {
|
||||
etaMinutes = services.CalculateETA(distanceKm)
|
||||
}
|
||||
|
||||
// Sauvegarder en cache
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
etaCache := map[string]interface{}{
|
||||
etaCache := map[string]any{
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
|
||||
@@ -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
|
||||
func GetAllDeliveryDistances(c *gin.Context) {
|
||||
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) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
@@ -347,12 +340,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
|
||||
log.Printf("🚗 %d livreur(s) actif(s)", activeCount)
|
||||
|
||||
// Récupérer les livreurs actifs avec capacité disponible
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
|
||||
// Si aucun livreur avec capacité disponible
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
// Cas 1: Un seul livreur actif -> pas de limite
|
||||
if activeCount == 1 {
|
||||
singleDeliveryman, err := database.GetSingleActiveDeliveryman()
|
||||
if err != nil {
|
||||
@@ -371,7 +361,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Passer les coordonnées à la fonction d'assignation
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement sans accès aux clés
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée au seul livreur actif (sans limite)",
|
||||
@@ -399,7 +387,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
"single_driver": true,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
@@ -410,13 +398,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée
|
||||
allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity()
|
||||
|
||||
if allAtCapacity && numActive > 1 {
|
||||
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()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -424,8 +410,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Calculer le temps de trajet avec TomTom
|
||||
travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -433,8 +417,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Assigner de force avec coordonnées
|
||||
err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"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,
|
||||
"traffic_aware": true,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"latitude": location.Latitude,
|
||||
@@ -474,7 +455,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas 3: Erreur générique
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
"error": "Aucun livreur actif avec capacité disponible",
|
||||
"active_count": activeCount,
|
||||
@@ -483,13 +463,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Cas normal: Au moins un livreur avec capacité disponible
|
||||
usernames := make([]string, len(activeLivreurs))
|
||||
for i, livreur := range activeLivreurs {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le livreur le plus proche (calcul rapide)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -498,7 +476,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Recalculer l'ETA avec TomTom pour plus de précision
|
||||
travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords)
|
||||
if err != nil {
|
||||
// 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)
|
||||
|
||||
// ✅ Assigner à la queue du livreur avec coordonnées
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
// ✅ CORRECTION: Utiliser etaData directement
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "Commande assignée à la queue du livreur",
|
||||
@@ -537,7 +512,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) {
|
||||
"single_driver": activeCount == 1,
|
||||
"traffic_aware": err == nil,
|
||||
},
|
||||
"eta": etaData, // ✅ Directement l'objet complet
|
||||
"eta": etaData,
|
||||
"delivery_address": address,
|
||||
"coordinates": gin.H{
|
||||
"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) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
geoService := c.MustGet("geoService").(*services.GeoService)
|
||||
@@ -559,7 +532,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer toutes les commandes pending
|
||||
commands, err := database.GetAllCommands("pending", "")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
@@ -585,7 +557,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
for _, cmd := range commands {
|
||||
commandID, ok := cmd["id"].(int)
|
||||
if !ok {
|
||||
// Essayer avec float64
|
||||
if idFloat, ok := cmd["id"].(float64); ok {
|
||||
commandID = int(idFloat)
|
||||
} else {
|
||||
@@ -593,7 +564,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'adresse
|
||||
address, ok := cmd["adresse"].(string)
|
||||
if !ok || address == "" || address == "Adresse non spécifiée" {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -603,7 +573,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Géocoder l'adresse
|
||||
location, err := geoService.GeocodeAddress(address)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -618,7 +587,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
Longitude: location.Longitude,
|
||||
}
|
||||
|
||||
// Récupérer les livreurs actifs
|
||||
activeLivreurs, err := database.GetAllActiveDeliveryPersons()
|
||||
if err != nil || len(activeLivreurs) == 0 {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -633,7 +601,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
usernames[i] = livreur.Username
|
||||
}
|
||||
|
||||
// Trouver le livreur le plus proche (version rapide pour assignation masse)
|
||||
nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames)
|
||||
if err != nil {
|
||||
failed = append(failed, gin.H{
|
||||
@@ -643,7 +610,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pour l'assignation en masse, on utilise le calcul rapide
|
||||
travelTime := nearest.EstimatedTime
|
||||
distance := nearest.Distance
|
||||
|
||||
@@ -657,13 +623,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mettre à jour le statut du livreur
|
||||
database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
|
||||
// Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement
|
||||
etaData, _ := database.GetCommandETA(commandID)
|
||||
|
||||
var totalETA, waitTime interface{}
|
||||
var totalETA, waitTime any
|
||||
totalETA = "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)
|
||||
}
|
||||
|
||||
// Récupérer l'overview des queues
|
||||
queuesOverview, _ := database.GetAllQueuesOverview()
|
||||
|
||||
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
|
||||
// GET /api/v2/admin/protected/delivery/queues
|
||||
func GetAllDeliveryQueues(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -723,7 +684,6 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les détails de chaque livreur
|
||||
var deliverymenDetails []gin.H
|
||||
|
||||
keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result()
|
||||
@@ -734,7 +694,7 @@ func GetAllDeliveryQueues(c *gin.Context) {
|
||||
|
||||
// Récupérer le statut
|
||||
statusData, _ := db.Redis.Get(db.RedisCtx, key).Result()
|
||||
var status map[string]interface{}
|
||||
var status map[string]any
|
||||
if statusData != "" {
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
|
||||
log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username)
|
||||
|
||||
// Récupérer la position GPS du livreur
|
||||
lat, lon, err := database.GetDeliveryPersonLocation(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [MAP_LINKS] Erreur position: %v", err)
|
||||
@@ -46,7 +45,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validation des coordonnées
|
||||
if lat == 0 && lon == 0 {
|
||||
log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username)
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -57,7 +55,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Générer les liens de cartes
|
||||
mapLinks := database.GenerateMapLinks(lat, lon, username)
|
||||
|
||||
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) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
username := c.GetString("username")
|
||||
@@ -100,7 +95,6 @@ func GetLivreurNavLink(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Priorité : coordonnées GPS de la destination
|
||||
var wazeLink string
|
||||
destLat, hasLat := command["dest_latitude"].(float64)
|
||||
destLon, hasLon := command["dest_longitude"].(float64)
|
||||
|
||||
@@ -4,92 +4,18 @@ import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"log"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"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
|
||||
// GET /api/v1/my-commands/history/detailed
|
||||
func GetMyCompletedOrdersWithItems(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_DETAILED] Utilisateur non authentifié")
|
||||
@@ -102,7 +28,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
usernameStr := username.(string)
|
||||
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)
|
||||
if err != nil {
|
||||
log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err)
|
||||
@@ -112,7 +37,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Enrichir chaque commande avec ses items
|
||||
var enrichedCommands []map[string]any
|
||||
|
||||
for _, command := range commands {
|
||||
@@ -121,7 +45,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Récupérer les items de cette commande
|
||||
items, err := database.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
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
|
||||
enrichedCommand := make(map[string]any)
|
||||
for k, v := range command {
|
||||
enrichedCommand[k] = v
|
||||
}
|
||||
maps.Copy(enrichedCommand, command)
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
@@ -141,7 +62,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
|
||||
log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands))
|
||||
|
||||
// ✅ Récupérer les infos client
|
||||
client, err := database.GetClientByUsername(usernameStr)
|
||||
|
||||
response := gin.H{
|
||||
@@ -168,7 +88,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) {
|
||||
func GetOrderHistory(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("❌ [ORDER_HISTORY] Utilisateur non authentifié")
|
||||
@@ -180,7 +99,6 @@ func GetOrderHistory(c *gin.Context) {
|
||||
|
||||
usernameStr := username.(string)
|
||||
|
||||
// Récupérer l'ID de la commande
|
||||
var commandID int
|
||||
if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil {
|
||||
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)
|
||||
|
||||
// ✅ Vérifier que la commande existe
|
||||
command, err := database.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ORDER_HISTORY] Commande non trouvée")
|
||||
@@ -201,7 +118,6 @@ func GetOrderHistory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Vérifier que la commande appartient au client
|
||||
cmdUsername, ok := command["username"].(string)
|
||||
if !ok || 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
|
||||
}
|
||||
|
||||
// ✅ Récupérer les logs de la commande
|
||||
logs, err := database.GetCommandLogs(commandID)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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))
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -20,7 +20,6 @@ func GetClientNotifications(c *gin.Context) {
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
// Récupérer toutes les notifications (max 50)
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
@@ -128,7 +127,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
|
||||
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]interface{}
|
||||
var n map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -171,7 +170,7 @@ func MarkNotificationsRead(c *gin.Context) {
|
||||
// Réécrire chaque notification avec read=true
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]interface{}
|
||||
var n map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -22,9 +22,6 @@ type BasketsRequest struct {
|
||||
Quantity float64 `json:"quantity"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ SÉCURISÉ: AddProductsBasket
|
||||
// ============================================
|
||||
// POST /api/v1/panier/add
|
||||
func AddProductsBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
@@ -90,7 +87,6 @@ func GetAllBaskets(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [GET_PANIER] Username manquant dans JWT")
|
||||
@@ -100,7 +96,6 @@ func GetAllBaskets(c *gin.Context) {
|
||||
|
||||
authUsernameStr := authUsername.(string)
|
||||
|
||||
// ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session
|
||||
if username != authUsernameStr {
|
||||
log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!")
|
||||
log.Printf(" Username du JWT: %s", authUsernameStr)
|
||||
@@ -111,10 +106,8 @@ func GetAllBaskets(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT
|
||||
username = authUsernameStr
|
||||
|
||||
// ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT
|
||||
_, err := database.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GET_PANIER] Client inexistant: %s", username)
|
||||
@@ -130,7 +123,7 @@ func GetAllBaskets(c *gin.Context) {
|
||||
|
||||
var totalAmount float64
|
||||
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)
|
||||
@@ -156,7 +149,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
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)
|
||||
|
||||
// ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client
|
||||
itemUsername, err := database.GetBasketItemOwner(req.ID)
|
||||
|
||||
if err != nil {
|
||||
@@ -187,7 +178,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Supprimer l'article
|
||||
err = database.DeleteProductFromBasket(req.ID)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur lors de la suppression", err)
|
||||
@@ -205,7 +195,6 @@ func DeleteProductFromBasket(c *gin.Context) {
|
||||
func ClearBasket(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé)
|
||||
authUsername, hasAuth := c.Get("username")
|
||||
if !hasAuth {
|
||||
log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT")
|
||||
@@ -262,8 +251,8 @@ func ValidateBasket(c *gin.Context) {
|
||||
var req struct {
|
||||
DeliveryAddress string `json:"delivery_address" binding:"required"`
|
||||
UseReferralBalance bool `json:"use_referral_balance"`
|
||||
PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto"
|
||||
PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto)
|
||||
PaymentMethod string `json:"payment_method"`
|
||||
PayCurrency string `json:"pay_currency"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
|
||||
@@ -277,7 +266,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
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 _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked {
|
||||
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)
|
||||
|
||||
// ============================================
|
||||
// 1️⃣ Vérifier que le panier n'est pas vide
|
||||
// ============================================
|
||||
items, err := database.GetBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
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))
|
||||
|
||||
// ============================================
|
||||
// 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant
|
||||
// ============================================
|
||||
var cartTotal float64
|
||||
for _, item := range items {
|
||||
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
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok && price == 0 {
|
||||
@@ -322,17 +303,13 @@ func ValidateBasket(c *gin.Context) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// Si récompense présente mais aucun produit payant → refuser
|
||||
if hasRewardItem && cartTotal <= 0 {
|
||||
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"})
|
||||
return
|
||||
}
|
||||
|
||||
// Récupérer les paramètres globaux (zones + parrainage)
|
||||
appSettings, _ := database.GetSettings()
|
||||
|
||||
// Récupérer le solde parrainage disponible (seulement si le système est activé)
|
||||
var referralBalance float64
|
||||
if req.UseReferralBalance && appSettings.ReferralEnabled {
|
||||
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
|
||||
@@ -366,8 +343,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
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
|
||||
if req.UseReferralBalance && referralBalance > 0 {
|
||||
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)
|
||||
}
|
||||
|
||||
// Vérifier que tous les produits du panier ont encore un prix actif
|
||||
unavailable, err := database.GetUnavailableBasketItems(usernameStr)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification produits", err)
|
||||
@@ -414,11 +388,11 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vérification option crypto
|
||||
isCrypto := req.PaymentMethod == "crypto"
|
||||
if isCrypto {
|
||||
np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
if !npOk || np == nil {
|
||||
npRaw, npExists := c.Get("nowpayments")
|
||||
np, npOk := npRaw.(*services.NowPaymentsClient)
|
||||
if !npExists || !npOk || np == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"})
|
||||
return
|
||||
}
|
||||
@@ -434,6 +408,10 @@ func ValidateBasket(c *gin.Context) {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
}
|
||||
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"})
|
||||
return
|
||||
}
|
||||
@@ -447,7 +425,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
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 {
|
||||
np := c.MustGet("nowpayments").(*services.NowPaymentsClient)
|
||||
ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c))
|
||||
@@ -460,7 +437,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
}
|
||||
payResp, err := np.CreatePayment(payReq)
|
||||
if err != nil {
|
||||
// Annuler la commande et restaurer le panier / parrainage
|
||||
_ = database.CancelCryptoCommand(commandID)
|
||||
if referralUsed > 0 {
|
||||
_ = database.CreditClientReferral(usernameStr, referralUsed)
|
||||
@@ -470,14 +446,21 @@ func ValidateBasket(c *gin.Context) {
|
||||
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 {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err)
|
||||
}
|
||||
|
||||
priceAmt, _ := payResp.PriceAmount.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)
|
||||
c.JSON(http.StatusCreated, gin.H{
|
||||
@@ -495,32 +478,8 @@ func ValidateBasket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Notifier immédiatement tous les admins et agents cabine
|
||||
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 assignInfo gin.H
|
||||
|
||||
@@ -540,7 +499,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
if err == nil {
|
||||
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(
|
||||
nearest.Location,
|
||||
services.Coordinates{
|
||||
@@ -558,7 +516,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance)
|
||||
|
||||
// Assigner la commande au livreur
|
||||
err = database.AssignCommandToDeliverymanQueueWithCoords(
|
||||
commandID,
|
||||
nearest.Username,
|
||||
@@ -571,13 +528,10 @@ func ValidateBasket(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err)
|
||||
} else {
|
||||
// Mettre à jour le statut du livreur
|
||||
err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID)
|
||||
if err != nil {
|
||||
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)
|
||||
if referralUsed > 0 {
|
||||
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 {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
||||
}
|
||||
|
||||
// Notifier le client
|
||||
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)
|
||||
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
|
||||
@@ -609,9 +561,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5️⃣ Réponse
|
||||
// ============================================
|
||||
newBalance, _ := database.GetClientReferralBalance(usernameStr)
|
||||
resp := gin.H{
|
||||
"success": true,
|
||||
@@ -638,7 +587,6 @@ func ValidateBasket(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// getBaseURL construit l'URL de base depuis la requête en cours
|
||||
func getBaseURL(c *gin.Context) string {
|
||||
scheme := "https"
|
||||
if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" {
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"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) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
targetUsername := c.Param("username")
|
||||
@@ -28,14 +26,12 @@ func SetClientParrainAdmin(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le parrain existe
|
||||
parrain, err := database.GetClientByUsername(req.Parrain)
|
||||
if err != nil || parrain == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"})
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le client n'a pas déjà un parrain
|
||||
existing, err := database.GetClientParrain(targetUsername)
|
||||
if err != nil {
|
||||
utils.ServerErr(c, "Erreur vérification parrain", err)
|
||||
@@ -46,25 +42,23 @@ func SetClientParrainAdmin(c *gin.Context) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "Parrain enregistré",
|
||||
"client": targetUsername,
|
||||
"parrain": req.Parrain,
|
||||
"amount_credited": settings.ReferralAmount,
|
||||
"amount_credited": creditAmount,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,34 @@ import (
|
||||
"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é.
|
||||
// La récompense est globale : son seuil s'applique indépendamment à chaque pool.
|
||||
func GetMyPointsRewards(c *gin.Context) {
|
||||
@@ -48,14 +76,22 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
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 {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Points int `json:"points"`
|
||||
RewardsEarned int `json:"rewards_earned"`
|
||||
RewardsClaimed int `json:"rewards_claimed"`
|
||||
RewardsAvailable int `json:"rewards_available"`
|
||||
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Points int `json:"points"`
|
||||
RewardsEarned int `json:"rewards_earned"`
|
||||
RewardsClaimed int `json:"rewards_claimed"`
|
||||
RewardsAvailable int `json:"rewards_available"`
|
||||
EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"`
|
||||
EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"`
|
||||
}
|
||||
|
||||
// Collecter tous les product_ids nécessaires en un seul passage
|
||||
@@ -73,6 +109,7 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
productNames, _ := database.GetProductNamesByIDs(allProductIDs)
|
||||
productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs)
|
||||
|
||||
pools := make([]PoolInfo, 0, len(settings.PointsPools))
|
||||
for _, pool := range settings.PointsPools {
|
||||
@@ -83,12 +120,9 @@ func GetMyPointsRewards(c *gin.Context) {
|
||||
if reward != nil && reward.Threshold > 0 {
|
||||
earned = pts / reward.Threshold
|
||||
available = earned - redeemed
|
||||
if available < 0 {
|
||||
available = 0
|
||||
}
|
||||
available = max(earned-redeemed, 0)
|
||||
}
|
||||
|
||||
// Filtrer les category_configs aux seules catégories du pool
|
||||
poolCats := make(map[string]bool, len(pool.Categories))
|
||||
for _, c := range pool.Categories {
|
||||
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{
|
||||
Key: pool.Key,
|
||||
Name: pool.Name,
|
||||
Points: pts,
|
||||
RewardsEarned: earned,
|
||||
RewardsClaimed: redeemed,
|
||||
RewardsAvailable: available,
|
||||
EligibleConfigs: eligibleConfigs,
|
||||
Key: pool.Key,
|
||||
Name: pool.Name,
|
||||
Points: pts,
|
||||
RewardsEarned: earned,
|
||||
RewardsClaimed: redeemed,
|
||||
RewardsAvailable: available,
|
||||
EligibleConfigs: eligibleConfigs,
|
||||
EligibleRewardItems: eligibleRewardItems,
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
if reward != nil {
|
||||
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
|
||||
@@ -168,7 +213,7 @@ func ClaimMyReward(c *gin.Context) {
|
||||
|
||||
var req struct {
|
||||
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 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"})
|
||||
@@ -194,53 +239,86 @@ func ClaimMyReward(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Vérifier que le pool existe
|
||||
poolExists := false
|
||||
for _, p := range settings.PointsPools {
|
||||
if p.Key == req.PoolKey {
|
||||
poolExists = true
|
||||
// Vérifier que le pool existe et récupérer ses catégories
|
||||
var selectedPool *models.PointsPool
|
||||
for i := range settings.PointsPools {
|
||||
if settings.PointsPools[i].Key == req.PoolKey {
|
||||
selectedPool = &settings.PointsPools[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if !poolExists {
|
||||
if selectedPool == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"})
|
||||
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 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"})
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Si le client a sélectionné un produit spécifique parmi plusieurs, ne donner que celui-là
|
||||
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
|
||||
productAdded := len(added) > 0
|
||||
var productNames []string
|
||||
if len(itemsToAdd) > 0 {
|
||||
if added, addErr := database.AddRewardsToBasket(username, itemsToAdd, req.PoolKey); addErr == nil && len(added) > 0 {
|
||||
productAdded = true
|
||||
for _, item := range added {
|
||||
productNames = append(productNames, item.ProductName)
|
||||
}
|
||||
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)
|
||||
}
|
||||
for _, item := range added {
|
||||
productNames = append(productNames, item.ProductName)
|
||||
}
|
||||
if productAdded {
|
||||
log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username)
|
||||
}
|
||||
|
||||
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) {
|
||||
username := c.Param("username")
|
||||
poolKey := c.Query("pool_key")
|
||||
|
||||
@@ -4,12 +4,12 @@ import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -116,7 +116,6 @@ func validateCategory(database *db.Database, category string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
|
||||
func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
@@ -130,7 +129,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
}
|
||||
|
||||
mimeType := mtype.String()
|
||||
// Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...")
|
||||
if idx := strings.Index(mimeType, ";"); idx != -1 {
|
||||
mimeType = strings.TrimSpace(mimeType[:idx])
|
||||
}
|
||||
@@ -142,28 +140,9 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) {
|
||||
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) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -172,14 +151,12 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
username, _ := safeGetUsername(c)
|
||||
|
||||
// ✅ PARSER AVEC LIMITE DE TAILLE
|
||||
if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER LES DONNÉES
|
||||
name := strings.TrimSpace(c.PostForm("name"))
|
||||
category := strings.TrimSpace(c.PostForm("category"))
|
||||
description := strings.TrimSpace(c.PostForm("description"))
|
||||
@@ -189,7 +166,6 @@ func CreateProduct(c *gin.Context) {
|
||||
unit = "u"
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
if err := validateProductName(name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -200,7 +176,6 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ NETTOYER ET VALIDER LA CATÉGORIE
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
category = strings.Map(func(r rune) rune {
|
||||
if r < 32 || r == 127 {
|
||||
@@ -219,7 +194,6 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VALIDER LE STOCK
|
||||
stock, err := strconv.ParseFloat(stockStr, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"})
|
||||
@@ -231,11 +205,10 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER LES PRIX
|
||||
prices := []models.ProductPrice{}
|
||||
priceIndex := 0
|
||||
|
||||
for priceIndex < 100 { // Limite anti-spam
|
||||
for priceIndex < 100 {
|
||||
quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex)
|
||||
priceKey := fmt.Sprintf("prices[%d][price]", priceIndex)
|
||||
activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex)
|
||||
@@ -323,7 +296,6 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ LIMITER LE NOMBRE DE FICHIERS
|
||||
if len(files) > MaxFilesPerProduct {
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -336,13 +308,13 @@ func CreateProduct(c *gin.Context) {
|
||||
|
||||
cleanProductName := cleanFileName(product.Name)
|
||||
uploadedMedia := []models.Media{}
|
||||
savedFiles := []string{}
|
||||
savedFiles := []models.Media{}
|
||||
storage := c.MustGet("storage").(services.Storage)
|
||||
var totalSize int64 = 0
|
||||
|
||||
for i, fileHeader := range files {
|
||||
// ✅ VÉRIFIER LA TAILLE INDIVIDUELLE
|
||||
if fileHeader.Size > MaxFileSize {
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"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
|
||||
|
||||
// ✅ VÉRIFIER LA TAILLE TOTALE
|
||||
if totalSize > MaxTotalUploadSize {
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"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)
|
||||
|
||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension)
|
||||
mimeType, err := validateFileMimeType(fileHeader)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ DÉTERMINER LE TYPE DE MÉDIA
|
||||
var mediaType string
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
mediaType = "image"
|
||||
} else if strings.HasPrefix(mimeType, "video/") {
|
||||
mediaType = "video"
|
||||
} else {
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ
|
||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename)
|
||||
|
||||
// ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE
|
||||
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)
|
||||
mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName)
|
||||
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)
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"})
|
||||
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{
|
||||
ProductID: product.ID,
|
||||
Type: mediaType,
|
||||
URL: mediaURL,
|
||||
Key: mediaKey,
|
||||
}
|
||||
|
||||
if err := database.CreateMedia(&media); err != nil {
|
||||
log.Printf("❌ [CreateProduct] Erreur DB média: %v", err)
|
||||
rollbackFiles(savedFiles)
|
||||
rollbackFiles(storage, savedFiles)
|
||||
database.DeleteProduct(product.ID)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||
return
|
||||
@@ -480,7 +424,6 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
|
||||
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateCategory(database, category); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"success": false,
|
||||
@@ -499,7 +442,6 @@ func GetProductsByCategory(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ Charger les médias
|
||||
for i := range products {
|
||||
media, _ := database.GetMediaByProductID(products[i].ID)
|
||||
products[i].Media = media
|
||||
@@ -533,11 +475,9 @@ func GetProductByID(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// ✅ Charger les médias
|
||||
media, _ := database.GetMediaByProductID(product.ID)
|
||||
product.Media = media
|
||||
|
||||
// ✅ Filtrer les prix désactivés (sauf pour admin/cabine)
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
filterActivepricesSingle(&product)
|
||||
@@ -552,7 +492,6 @@ func GetProductByID(c *gin.Context) {
|
||||
func UpdateProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -567,7 +506,6 @@ func UpdateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
_, err = database.GetProductByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
|
||||
@@ -589,7 +527,6 @@ func UpdateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
if err := validateProductName(updateData.Name); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -733,8 +670,8 @@ func UpdateStock(c *gin.Context) {
|
||||
|
||||
func DeleteMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
@@ -753,28 +690,27 @@ func DeleteMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION
|
||||
filePath := strings.TrimPrefix(media.URL, "/")
|
||||
|
||||
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 {
|
||||
if err := database.DeleteMedia(mediaID); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"})
|
||||
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{
|
||||
"success": true,
|
||||
"message": "Média supprimé",
|
||||
@@ -784,7 +720,6 @@ func DeleteMedia(c *gin.Context) {
|
||||
func UploadMedia(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
// ✅ VÉRIFIER LE RÔLE
|
||||
username, err := safeGetUsername(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
|
||||
@@ -792,19 +727,17 @@ func UploadMedia(c *gin.Context) {
|
||||
}
|
||||
|
||||
role := c.GetString("role")
|
||||
if role != "admin" && role != "cabine" {
|
||||
if role != "admin" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT
|
||||
productID, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil || productID <= 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
productName, err := database.GetProductNameByID(productID)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
// ✅ RÉCUPÉRER LE TYPE ET LE FICHIER
|
||||
fileType := c.PostForm("type")
|
||||
if fileType != "image" && fileType != "video" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"})
|
||||
@@ -827,7 +759,6 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER LA TAILLE
|
||||
const MaxFileSize = 10 * 1024 * 1024 // 10MB
|
||||
if file.Size > MaxFileSize {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
@@ -836,7 +767,6 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER LE TYPE MIME RÉEL
|
||||
detectedMime, err := validateFileMimeType(file)
|
||||
if err != nil {
|
||||
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)
|
||||
|
||||
// Vérifier que le MIME correspond au type déclaré
|
||||
if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"})
|
||||
return
|
||||
@@ -856,40 +785,32 @@ func UploadMedia(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ GÉNÉRER UN NOM UNIQUE
|
||||
cleanProductName := cleanFileName(productName)
|
||||
uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename)
|
||||
|
||||
// ✅ CRÉER LE DOSSIER
|
||||
destFolder := filepath.Join("uploads", fileType+"s")
|
||||
if err := os.MkdirAll(destFolder, 0750); err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"})
|
||||
storage := c.MustGet("storage").(services.Storage)
|
||||
folder := fileType + "s"
|
||||
mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UploadMedia] Erreur upload: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SAUVEGARDER LE FICHIER
|
||||
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 uploadé: %s", mediaURL)
|
||||
|
||||
log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath)
|
||||
|
||||
// ✅ CRÉER L'ENTRÉE EN BASE
|
||||
mediaURL := "/" + filepath.ToSlash(filePath)
|
||||
media := models.Media{
|
||||
ProductID: productID,
|
||||
Type: fileType,
|
||||
URL: mediaURL,
|
||||
Key: mediaKey,
|
||||
}
|
||||
|
||||
err = database.CreateMedia(&media)
|
||||
if err != nil {
|
||||
// Rollback: supprimer le fichier
|
||||
os.Remove(filePath)
|
||||
if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil {
|
||||
log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr)
|
||||
}
|
||||
log.Printf("❌ [UploadMedia] Erreur DB: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"})
|
||||
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) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
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"})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DELETE PRODUCT - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func DeleteProduct(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
|
||||
@@ -976,32 +915,28 @@ func DeleteProduct(c *gin.Context) {
|
||||
|
||||
log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id)
|
||||
|
||||
// ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION
|
||||
mediaList, err := database.GetMediaByProductID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ
|
||||
s3Service := c.MustGet("s3Service").(*services.S3Service)
|
||||
localStorage := services.NewLocalStorage("uploads")
|
||||
for _, media := range mediaList {
|
||||
filePath := strings.TrimPrefix(media.URL, "/")
|
||||
|
||||
safeFilePath, err := sanitizeFilePath(filePath)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err)
|
||||
if media.Key != "" {
|
||||
if err := s3Service.DeleteFile(media.Key); err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err)
|
||||
}
|
||||
} else {
|
||||
if err := localStorage.Delete(media.URL, ""); err != nil {
|
||||
log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER LES MÉDIAS DE LA DB
|
||||
database.DeleteMediaByProductID(id)
|
||||
|
||||
// ✅ SUPPRIMER LE PRODUIT
|
||||
err = database.DeleteProduct(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"})
|
||||
@@ -1016,17 +951,11 @@ func DeleteProduct(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// HELPERS
|
||||
// ============================================
|
||||
|
||||
func rollbackFiles(files []string) {
|
||||
for _, file := range files {
|
||||
safeFilePath, err := sanitizeFilePath(file)
|
||||
if err != nil {
|
||||
continue
|
||||
func rollbackFiles(storage services.Storage, files []models.Media) {
|
||||
for _, f := range files {
|
||||
if err := storage.Delete(f.URL, f.Key); err != nil {
|
||||
log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err)
|
||||
}
|
||||
os.Remove(safeFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -307,19 +307,22 @@ func GetDeliverymanLocationForCommand(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ✅ 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)
|
||||
etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result()
|
||||
eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result()
|
||||
|
||||
var etaMinutes int = 0
|
||||
var etaSetAt int64 = 0
|
||||
if etaData != "" {
|
||||
var eta map[string]interface{}
|
||||
json.Unmarshal([]byte(etaData), &eta)
|
||||
if minutes, ok := eta["minutes"].(float64); ok {
|
||||
etaMinutes = int(minutes)
|
||||
if minutesStr, ok := eta["eta_minutes"]; ok {
|
||||
if minutes, err := strconv.Atoi(minutesStr); err == nil {
|
||||
etaMinutes = 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) {
|
||||
// 1. Récupérer le statut actuel du livreur
|
||||
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)
|
||||
|
||||
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"eta_minutes": etaMinutes,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"distance_km": distanceKm,
|
||||
"with_traffic": err == nil,
|
||||
"command_id": commandID,
|
||||
// eta_minutes ET total_eta_minutes doivent tous les deux être présents
|
||||
// (voir le commentaire de SetCommandETAWithDetails) — sans quoi les
|
||||
// lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien.
|
||||
"eta_minutes": etaMinutes,
|
||||
"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)
|
||||
}
|
||||
|
||||
@@ -48,6 +48,13 @@ func GetPublicSettings(c *gin.Context) {
|
||||
"shop_name": settings.ShopName,
|
||||
"two_fa_enabled": settings.Telegram2FAEnabled,
|
||||
"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
@@ -1,38 +1,270 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
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.
|
||||
func GetAdminStats(c *gin.Context) {
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
gdb := database.GDB
|
||||
|
||||
// ── Commandes par jour de la semaine (all time, non annulées) ──────────────
|
||||
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)
|
||||
filters := database.LoadAdminStatsFilters()
|
||||
|
||||
// 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)
|
||||
wdMap := make(map[int]int, len(wdRows))
|
||||
for _, r := range wdRows {
|
||||
wdMap[r.DOW] = r.Count
|
||||
}
|
||||
peakCount, peakWeekday := 0, ""
|
||||
for i := 0; i < 7; i++ {
|
||||
for i := range 7 {
|
||||
cnt := wdMap[i]
|
||||
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
||||
if cnt > peakCount {
|
||||
@@ -42,16 +274,6 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
|
||||
// ── 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))
|
||||
for i, r := range dayRows {
|
||||
byDay[i] = gin.H{
|
||||
@@ -61,17 +283,7 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────
|
||||
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)
|
||||
|
||||
// ── Revenus par jour sur 30 jours ─────────────────────────────────────────
|
||||
byDayRevenue := make([]gin.H, len(dayRevRows))
|
||||
for i, r := range dayRevRows {
|
||||
byDayRevenue[i] = gin.H{
|
||||
@@ -81,25 +293,13 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Commandes & revenus par heure (all time, non annulées) ───────────────
|
||||
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)
|
||||
|
||||
// ── Commandes & revenus par heure ─────────────────────────────────────────
|
||||
hourMap := make(map[int]models.HourRow, len(hourRows))
|
||||
for _, r := range hourRows {
|
||||
hourMap[r.Hour] = r
|
||||
}
|
||||
byHour := make([]gin.H, 24)
|
||||
for h := 0; h < 24; h++ {
|
||||
for h := range 24 {
|
||||
r := hourMap[h]
|
||||
byHour[h] = gin.H{
|
||||
"hour": h,
|
||||
@@ -109,27 +309,7 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
|
||||
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)
|
||||
|
||||
// ── Top produits ──────────────────────────────────────────────────────────
|
||||
topProducts := make([]gin.H, len(prodRows))
|
||||
topProductName := ""
|
||||
for i, r := range prodRows {
|
||||
@@ -147,26 +327,7 @@ func GetAdminStats(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Répartition des doses/quantités par produit ───────────────────────────
|
||||
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)
|
||||
|
||||
// ── Répartition des doses/quantités ───────────────────────────────────────
|
||||
type productGroup struct {
|
||||
ProductID int
|
||||
Name string
|
||||
@@ -195,7 +356,6 @@ func GetAdminStats(c *gin.Context) {
|
||||
"revenue": r.Revenue,
|
||||
})
|
||||
}
|
||||
// Trier par total de commandes décroissant, garder 15 max
|
||||
for i := 0; i < len(groups)-1; i++ {
|
||||
for j := i + 1; j < len(groups); j++ {
|
||||
if groups[j].TotalOrders > groups[i].TotalOrders {
|
||||
@@ -207,39 +367,65 @@ func GetAdminStats(c *gin.Context) {
|
||||
groups = groups[:15]
|
||||
}
|
||||
byQuantity := make([]gin.H, len(groups))
|
||||
for i, g := range groups {
|
||||
for i, grp := range groups {
|
||||
byQuantity[i] = gin.H{
|
||||
"product_id": g.ProductID,
|
||||
"name": g.Name,
|
||||
"category_color": g.CategoryColor,
|
||||
"total_orders": g.TotalOrders,
|
||||
"quantities": g.Quantities,
|
||||
"product_id": grp.ProductID,
|
||||
"name": grp.Name,
|
||||
"category_color": grp.CategoryColor,
|
||||
"total_orders": grp.TotalOrders,
|
||||
"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 ─────────────────────────────────────────────────────────
|
||||
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
|
||||
if totalOrders > 0 {
|
||||
// average over the last 30 days with data
|
||||
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)
|
||||
}
|
||||
if totalOrders > 0 && activeDays > 0 {
|
||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
@@ -250,11 +436,24 @@ func GetAdminStats(c *gin.Context) {
|
||||
"top_product": topProductName,
|
||||
"avg_per_day": avgPerDay,
|
||||
},
|
||||
"by_weekday": byWeekday,
|
||||
"by_day_30": byDay,
|
||||
"by_day_revenue": byDayRevenue,
|
||||
"by_hour": byHour,
|
||||
"top_products": topProducts,
|
||||
"by_quantity": byQuantity,
|
||||
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
||||
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
||||
"reset_at_produits": dateFilter(filters.ResetProduits),
|
||||
"reset_at_heures": dateFilter(filters.ResetHeures),
|
||||
"reset_at_jours": dateFilter(filters.ResetJours),
|
||||
"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
|
||||
}
|
||||
|
||||
// ✅ 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
|
||||
client, err := database.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
|
||||
+26
-1
@@ -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("🧹 Démarrage du nettoyage des commandes invalides...")
|
||||
removed, err := database.CleanupInvalidQueueCommands()
|
||||
@@ -153,6 +176,8 @@ func main() {
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("database", database)
|
||||
c.Set("geoService", geoService)
|
||||
c.Set("s3Service", s3Service)
|
||||
c.Set("storage", storage)
|
||||
c.Next()
|
||||
})
|
||||
|
||||
@@ -167,7 +192,7 @@ func main() {
|
||||
|
||||
r.Static("/uploads", "./uploads")
|
||||
|
||||
routes.SetupRoutes(r, database, geoService)
|
||||
routes.SetupRoutes(r, database, geoService, s3Service)
|
||||
|
||||
if err := r.Run(":8080"); err != nil {
|
||||
log.Fatalf("❌ Erreur au lancement du serveur : %v", err)
|
||||
|
||||
@@ -3,11 +3,12 @@ package models
|
||||
import "time"
|
||||
|
||||
type Media struct {
|
||||
ID int `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ProductID int `gorm:"column:product_id" json:"product_id"`
|
||||
Type string `gorm:"column:type" json:"type"`
|
||||
URL string `gorm:"column:url" json:"url"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
ID int `json:"id"`
|
||||
ProductID int `json:"product_id"`
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Key string `json:"-"` // clé interne RustFS, jamais exposée
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Media) TableName() string { return "media" }
|
||||
|
||||
@@ -106,4 +106,19 @@ type AppSettings struct {
|
||||
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
|
||||
Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs
|
||||
ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact
|
||||
// Palette de couleurs — espace admin
|
||||
AdminColorPrimary string `json:"admin_color_primary"`
|
||||
AdminColorSecondary string `json:"admin_color_secondary"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -42,3 +42,35 @@ type DayRevenueRow struct {
|
||||
Day time.Time `gorm:"column:day"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"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
|
||||
@@ -17,6 +17,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set("database", database)
|
||||
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
|
||||
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
|
||||
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)
|
||||
// ============================================
|
||||
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)
|
||||
@@ -135,6 +145,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// ============================================
|
||||
router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink)
|
||||
|
||||
// ============================================
|
||||
// 📋 HEALTH CHECK
|
||||
// ============================================
|
||||
router.GET("/health", handlers.Health)
|
||||
|
||||
// ============================================
|
||||
// 📋 PATTERN v2: ADMIN API
|
||||
// ============================================
|
||||
@@ -197,16 +212,18 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// CATÉGORIES - GESTION ADMIN
|
||||
// ============================================
|
||||
adminGroupV2.POST("/categories", handlers.CreateCategory)
|
||||
adminGroupV2.PUT("/categories/reorder", handlers.ReorderCategories)
|
||||
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
|
||||
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
|
||||
// ============================================
|
||||
// STATISTIQUES ADMIN
|
||||
// ============================================
|
||||
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("/desactive/product/price/:id", handlers.DesActivePrice)
|
||||
|
||||
adminGroupV2.GET("/stats/daily", handlers.GetAdminDailyDetail)
|
||||
// ============================================
|
||||
// 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.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue)
|
||||
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
|
||||
adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders)
|
||||
// ============================================
|
||||
@@ -392,6 +411,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
// ============================================
|
||||
// NOTIFICATIONS LIVREUR
|
||||
// ============================================
|
||||
livreurGroupV1.GET("/ratings", handlers.GetMyRatings)
|
||||
|
||||
livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
|
||||
livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
|
||||
|
||||
|
||||
@@ -3,17 +3,13 @@ package services
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/utils"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"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) {
|
||||
rawAddress = strings.TrimSpace(rawAddress)
|
||||
if rawAddress == "" {
|
||||
return nil, fmt.Errorf("adresse vide")
|
||||
}
|
||||
|
||||
// ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ──
|
||||
if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil {
|
||||
if loc, err := acs.geoService.getFromCache(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{
|
||||
OriginalAddress: 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)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 2 : FUZZY SEARCH NOMINATIM
|
||||
// ============================================
|
||||
|
||||
// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse
|
||||
func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) {
|
||||
variants := buildAddressVariants(address)
|
||||
|
||||
@@ -131,7 +121,7 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
|
||||
CorrectedAddress: corrected,
|
||||
Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude},
|
||||
Confidence: confidence,
|
||||
CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)),
|
||||
CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)),
|
||||
Source: "fuzzy",
|
||||
}, nil
|
||||
}
|
||||
@@ -140,7 +130,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr
|
||||
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) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
@@ -172,7 +161,7 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
|
||||
defer resp.Body.Close()
|
||||
|
||||
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)
|
||||
@@ -196,7 +185,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]
|
||||
func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) {
|
||||
parts := parseAddressParts(address)
|
||||
|
||||
// Essai 1 : numéro + rue + ville (sans code postal)
|
||||
if 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 {
|
||||
@@ -206,7 +194,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS
|
||||
}
|
||||
}
|
||||
|
||||
// Essai 2 : rue + code postal uniquement
|
||||
if parts.streetName != "" && parts.postcode != "" {
|
||||
q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode)
|
||||
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 != "" {
|
||||
q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode)
|
||||
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
|
||||
func buildAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
normalized := normalize(address)
|
||||
normalized := utils.NormalizeAddress(address)
|
||||
|
||||
// Variante sans accents
|
||||
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
|
||||
func computeConfidence(original, suggested string, nominatimImportance float64) float64 {
|
||||
origNorm := normalize(strings.ToLower(original))
|
||||
suggNorm := normalize(strings.ToLower(suggested))
|
||||
origNorm := utils.NormalizeAddress(strings.ToLower(original))
|
||||
suggNorm := utils.NormalizeAddress(strings.ToLower(suggested))
|
||||
|
||||
// Score de similarité sur les mots communs
|
||||
origWords := strings.Fields(origNorm)
|
||||
@@ -449,13 +435,6 @@ func formatNominatimAddress(s NominatimSuggestion) string {
|
||||
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
|
||||
func isPostcode(s string) bool {
|
||||
if len(s) != 5 {
|
||||
|
||||
@@ -72,19 +72,14 @@ func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) {
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 2. TomTom (primaire — plus fiable que Nominatim pour les adresses FR)
|
||||
if location, err := GeocodeWithTomTom(address); err == nil {
|
||||
gs.saveToCache(address, location)
|
||||
return location, nil
|
||||
}
|
||||
|
||||
// 3. Fallback Nominatim
|
||||
// 2. Tentative directe via Nominatim
|
||||
if location, err := gs.fetchFromNominatim(address); err == nil {
|
||||
gs.saveToCache(address, location)
|
||||
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)
|
||||
|
||||
suggestion, err := gs.correctionService.ResolveAddress(address)
|
||||
@@ -216,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string {
|
||||
return fmt.Sprintf("geocode:cache:%s", address)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CALCULS GÉOGRAPHIQUES
|
||||
// ============================================
|
||||
|
||||
// CalculateDistance calcule la distance entre deux points (formule Haversine)
|
||||
func CalculateDistance(from, to Coordinates) float64 {
|
||||
// Conversion en radians
|
||||
@@ -228,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 {
|
||||
lat2Rad := toRadians(to.Latitude)
|
||||
lon2Rad := toRadians(to.Longitude)
|
||||
|
||||
// Différences
|
||||
dLat := lat2Rad - lat1Rad
|
||||
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)
|
||||
func CalculateETA(distanceKm float64) int {
|
||||
// ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance
|
||||
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
|
||||
|
||||
// Ajouter une marge pour le trafic (environ 20%)
|
||||
@@ -267,8 +254,6 @@ func CalculateETA(distanceKm float64) int {
|
||||
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) {
|
||||
if len(tomTomKeys.keys) == 0 {
|
||||
distance := CalculateDistance(from, to)
|
||||
|
||||
@@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool {
|
||||
// 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).
|
||||
func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"user_id": chatID,
|
||||
"username": username,
|
||||
"role": role,
|
||||
@@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro
|
||||
// SendNotification envoie un message via la gateway LBTelegram.
|
||||
// Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn).
|
||||
func (s *LBTelegramService) SendNotification(userID int64, message string) error {
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"user_id": userID,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error {
|
||||
return fmt.Errorf("telegram non configuré")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"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]})
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"reply_markup": map[string]interface{}{
|
||||
"reply_markup": map[string]any{
|
||||
"inline_keyboard": [][]map[string]string{row},
|
||||
},
|
||||
}
|
||||
@@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error {
|
||||
return fmt.Errorf("telegram non configuré")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
payload := map[string]any{
|
||||
"url": webhookURL,
|
||||
"allowed_updates": []string{"message"},
|
||||
}
|
||||
|
||||
@@ -15,72 +15,6 @@ import (
|
||||
"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) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ func (m *tomTomKeyManager) rotate(fromIdx int) {
|
||||
}
|
||||
|
||||
// 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) {
|
||||
n := len(m.keys)
|
||||
if n == 0 {
|
||||
@@ -67,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h
|
||||
|
||||
_, startIdx := m.currentKey()
|
||||
|
||||
for attempt := 0; attempt < n; attempt++ {
|
||||
for attempt := range n {
|
||||
idx := (startIdx + attempt) % n
|
||||
key := m.keys[idx]
|
||||
|
||||
req, err := buildReq(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
m.rotate(idx)
|
||||
continue
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user