diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go
index 30b80975..d11f119b 100644
--- a/backend/gestion/db/db_basket.go
+++ b/backend/gestion/db/db_basket.go
@@ -7,6 +7,25 @@ import (
"gorm.io/gorm"
)
+// GetActiveProductPrice retourne le prix catalogue actif pour un produit et
+// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
+// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
+// "half_price_product" (50% de ce prix).
+func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
+ var result struct {
+ Price float64 `gorm:"column:price"`
+ }
+ err := d.GDB.Raw(`
+ SELECT price FROM product_prices
+ WHERE product_id = ? AND quantity <= ? AND active_price = true
+ ORDER BY quantity DESC LIMIT 1`,
+ productID, quantity).Scan(&result).Error
+ if err != nil || result.Price == 0 {
+ return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
+ }
+ return result.Price, nil
+}
+
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var result struct {
Price float64 `gorm:"column:price"`
@@ -42,7 +61,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
return baskets, nil
}
-// AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true).
+// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
+// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
+// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
// Supprime les anciens items récompense avant d'insérer les nouveaux.
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
@@ -78,9 +99,9 @@ func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardIte
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)
+ VALUES (?, ?, ?, ?, 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 {
+ username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil {
return nil, err
}
baskets = append(baskets, basket)
diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go
index a1be038e..5427ffea 100644
--- a/backend/gestion/db/db_commands.go
+++ b/backend/gestion/db/db_commands.go
@@ -462,6 +462,36 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
return nil
}
+// UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA
+// PROPRE commande, tant qu'elle n'est pas encore prise en charge par un
+// livreur (statut "en_route") ni terminée. La vérification d'appartenance et
+// de statut se fait dans la clause WHERE, atomiquement : impossible de
+// modifier la commande d'un autre client ou une commande déjà en route.
+func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error {
+ if err := validateAddress(deliveryAddress); err != nil {
+ return err
+ }
+
+ result := d.GDB.Exec(`
+ UPDATE commandes
+ SET adresse = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`,
+ deliveryAddress, commandID, clientUsername)
+ if result.Error != nil {
+ return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
+ }
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("commande introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client")
+ }
+
+ d.AddCommandLog(commandID, "address_updated",
+ fmt.Sprintf("Adresse corrigée par le client %s", clientUsername),
+ clientUsername)
+
+ log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername)
+ return nil
+}
+
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
if err := validateAddress(proposedAddress); err != nil {
@@ -625,7 +655,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
cmd.Status, cmd.Username, cmd.LivreurAssign)
- validStatuses := []string{"assigned", "en_route", "pending", "livre"}
+ validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
if !slices.Contains(validStatuses, cmd.Status) {
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
@@ -862,13 +892,25 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
return fmt.Errorf("commande non trouvée")
}
- if cmd.Status != "livre" {
- return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
+ // Historiquement restreint à "livre" seul (cf. commentaire de
+ // TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
+ // incident réel où une vérification GPS en amont (coordonnées de
+ // destination périmées après un changement d'adresse, cf.
+ // updateCommandDestinationCoords) a bloqué la transition du livreur
+ // vers "livre" : la commande restait alors coincée, sans qu'admin ni
+ // cabine ne puissent confirmer la réception. On accepte désormais tout
+ // statut non terminal ("arrived" inclus), à l'image de
+ // ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
+ // pour que le staff garde toujours un moyen de débloquer une commande
+ // légitime indépendamment d'un blocage en amont côté livreur.
+ validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
+ if !slices.Contains(validStatuses, cmd.Status) {
+ return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
}
result := tx.Exec(`
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
- WHERE id = ? AND status = 'livre'`, commandID)
+ WHERE id = ? AND status = ?`, commandID, cmd.Status)
if result.Error != nil {
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
}
diff --git a/backend/gestion/db/db_product.go b/backend/gestion/db/db_product.go
index 093ff385..6a7e4506 100644
--- a/backend/gestion/db/db_product.go
+++ b/backend/gestion/db/db_product.go
@@ -5,6 +5,8 @@ import (
"gestion/models"
"log"
"time"
+
+ "gorm.io/gorm"
)
// CreateProduct crée un nouveau produit avec ses prix
@@ -262,14 +264,27 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
return nil
}
+// SetProductStock fixe le stock à une valeur absolue. Le verrou FOR UPDATE
+// sérialise cette écriture avec les décréments du checkout (db_commands.go) :
+// sans lui, une modification admin pourrait écraser silencieusement le
+// décrément d'une commande passée au même instant sur le même produit.
func (d *Database) SetProductStock(productID int, stock float64) error {
- result := d.GDB.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
- stock, time.Now(), productID)
- if result.Error != nil {
- return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
- }
- if result.RowsAffected == 0 {
- return fmt.Errorf("produit non trouvé")
+ err := d.GDB.Transaction(func(tx *gorm.DB) error {
+ var exists int
+ if err := tx.Raw(`SELECT 1 FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&exists).Error; err != nil {
+ return fmt.Errorf("erreur verrouillage produit: %w", err)
+ }
+ if exists == 0 {
+ return fmt.Errorf("produit non trouvé")
+ }
+ if err := tx.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
+ stock, time.Now(), productID).Error; err != nil {
+ return fmt.Errorf("erreur mise à jour stock: %w", err)
+ }
+ return nil
+ })
+ if err != nil {
+ return err
}
return nil
}
diff --git a/backend/gestion/db/db_stat.go b/backend/gestion/db/db_stat.go
index 9c283d8b..a0d52e4d 100644
--- a/backend/gestion/db/db_stat.go
+++ b/backend/gestion/db/db_stat.go
@@ -37,11 +37,14 @@ func (d *Database) ReadResetAt(key string) time.Time {
// ── Construction des clauses WHERE (filtrage par reset) ────────────────────
-// statusFilterClause construit " [AND created_at >= ?]" et renvoie
-// la clause ainsi que les arguments à binder, dans l'ordre.
-func statusFilterClause(baseStatus string, resetAt time.Time) (string, []interface{}) {
+// statusFilterClause construit " [AND >= ?]" et
+// renvoie la clause ainsi que les arguments à binder, dans l'ordre.
+// dateColumn doit être qualifié par l'alias de table (ex: "c.created_at") dès
+// que la requête appelante fait une jointure où plusieurs tables possèdent une
+// colonne created_at, sous peine d'erreur Postgres "ambiguous column".
+func statusFilterClause(baseStatus string, resetAt time.Time, dateColumn string) (string, []interface{}) {
if !resetAt.IsZero() {
- return baseStatus + " AND created_at >= ?", []interface{}{resetAt.Format(time.RFC3339)}
+ return baseStatus + " AND " + dateColumn + " >= ?", []interface{}{resetAt.Format(time.RFC3339)}
}
return baseStatus, nil
}
@@ -92,7 +95,7 @@ func (d *Database) LoadAdminStatsFilters() AdminStatsFilters {
// ── 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)
+ where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
FROM commandes
@@ -106,7 +109,7 @@ func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt tim
// ── Commandes par jour sur 30 jours ──────────────────────────────────────────
func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error {
- where, args := statusFilterClause("status != 'cancelled'", resetAt)
+ where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
@@ -121,7 +124,7 @@ func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time
// ── 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)
+ where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
query := `
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
@@ -142,11 +145,19 @@ type DailyMonthStatRow struct {
Quantity float64
}
-func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetAt time.Time) error {
+// StatsByDayForMonth applique resetCommandes au comptage (count) et à la
+// quantité (quantity, qui reflète le volume de commandes comme count), et
+// resetRevenus au revenu (revenue) — chaque métrique doit respecter la même
+// section de reset que son équivalent dans le résumé global (TotalOrders /
+// TotalRevenue), sous peine d'afficher des chiffres incohérents entre eux
+// après une réinitialisation partielle.
+func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetCommandes time.Time, resetRevenus 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)
+ whereCount, argsCount := statusFilterClause("status != 'cancelled'", resetCommandes, "created_at")
+ whereRevenue, argsRevenue := statusFilterClause("status = 'approved'", resetRevenus, "created_at")
+ whereQuantity, argsQuantity := statusFilterClause("c.status != 'cancelled'", resetCommandes, "c.created_at")
query := `
SELECT
@@ -158,7 +169,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
SELECT DATE(created_at) AS day, COUNT(*) AS count
FROM commandes
WHERE created_at >= ? AND created_at < ?
- AND ` + where + `
+ AND ` + whereCount + `
GROUP BY DATE(created_at)
) d
LEFT JOIN (
@@ -166,7 +177,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
FROM commandes
WHERE created_at >= ? AND created_at < ?
- AND status = 'approved'
+ AND ` + whereRevenue + `
GROUP BY DATE(created_at)
) rv ON rv.day = d.day
LEFT JOIN (
@@ -174,18 +185,20 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
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'
+ AND ` + whereQuantity + `
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".
+ // Ordre des "?" dans la requête : (start, end, [resetCommandes]) pour "d",
+ // puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt".
args := []interface{}{start, end}
- args = append(args, whereArgs...)
+ args = append(args, argsCount...)
args = append(args, start, end)
+ args = append(args, argsRevenue...)
args = append(args, start, end)
+ args = append(args, argsQuantity...)
return d.GDB.Raw(query, args...).Scan(rows).Error
}
@@ -195,7 +208,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
// 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)
+ where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
query := `
SELECT
EXTRACT(HOUR FROM created_at)::int AS hour,
@@ -215,7 +228,7 @@ func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt ti
// 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)
+ where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
args = append(args, limit)
query := `
SELECT
@@ -245,7 +258,7 @@ func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time,
// 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)
+ where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
query := `
SELECT
ci.product_id,
@@ -351,7 +364,7 @@ func (d *Database) DailyOrdersCount() (int64, error) {
// 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)
+ where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var total int64
err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error
return total, err
@@ -359,7 +372,7 @@ func (d *Database) TotalOrders(resetAt time.Time) (int64, error) {
// 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)
+ where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
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
@@ -368,7 +381,7 @@ func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
// 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)
+ where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var activeDays int64
query := `
SELECT COUNT(DISTINCT DATE(created_at))
@@ -380,7 +393,7 @@ func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
// 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)
+ where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
var count int64
query := `
SELECT COUNT(*) FROM commandes
diff --git a/backend/gestion/handlers/cabine.go b/backend/gestion/handlers/cabine.go
new file mode 100644
index 00000000..76ccab24
--- /dev/null
+++ b/backend/gestion/handlers/cabine.go
@@ -0,0 +1,165 @@
+package handlers
+
+import (
+ "gestion/db"
+ "net/http"
+ "strconv"
+
+ "github.com/gin-gonic/gin"
+)
+
+func GetLivreurPosition(c *gin.Context) {
+ database := c.MustGet("database").(*db.Database)
+ livreurUsername := c.Param("username")
+
+ userRole := c.GetString("role")
+ if userRole != "admin" && userRole != "cabine" {
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": "Accès refusé - Réservé aux administrateurs et cabines",
+ })
+ return
+ }
+
+ if livreurUsername == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Username livreur requis"})
+ return
+ }
+
+ position, err := database.GetLivreurPosition(livreurUsername)
+ if err != nil {
+ c.JSON(http.StatusOK, gin.H{
+ "success": false,
+ "livreur": livreurUsername,
+ "message": "Position non disponible (GPS désactivé ou livraison terminée)",
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "livreur": livreurUsername,
+ "position": position,
+ })
+}
+
+func GetDeliveryIssues(c *gin.Context) {
+ database := c.MustGet("database").(*db.Database)
+
+ status := c.Query("status")
+
+ userRole := c.GetString("role")
+ if userRole != "admin" && userRole != "cabine" {
+ c.JSON(http.StatusForbidden, gin.H{
+ "error": "Accès refusé - Réservé aux administrateurs et cabines",
+ })
+ return
+ }
+ issues, err := database.GetDeliveryIssues(status)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Erreur récupération problèmes",
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "issues": issues,
+ "count": len(issues),
+ })
+}
+
+func CreateDeliveryIssue(c *gin.Context) {
+ database := c.MustGet("database").(*db.Database)
+
+ var req struct {
+ CommandID int `json:"command_id" binding:"required"`
+ IssueType string `json:"issue_type" binding:"required"`
+ Description string `json:"description" binding:"required"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
+ return
+ }
+
+ cabineUsername, _ := c.Get("username")
+
+ issue, err := database.CreateDeliveryIssue(
+ req.CommandID,
+ req.IssueType,
+ req.Description,
+ cabineUsername.(string),
+ )
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Erreur création problème",
+ })
+ return
+ }
+
+ c.JSON(http.StatusCreated, gin.H{
+ "success": true,
+ "message": "Problème enregistré",
+ "issue": issue,
+ })
+}
+
+func UpdateDeliveryIssue(c *gin.Context) {
+ database := c.MustGet("database").(*db.Database)
+
+ issueID, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
+ return
+ }
+
+ var req struct {
+ Status string `json:"status"`
+ Resolution string `json:"resolution"`
+ }
+
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
+ return
+ }
+
+ cabineUsername, _ := c.Get("username")
+
+ err = database.UpdateDeliveryIssue(issueID, req.Status, req.Resolution, cabineUsername.(string))
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Erreur mise à jour",
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Problème mis à jour",
+ })
+}
+
+func GetCommandLogs(c *gin.Context) {
+ database := c.MustGet("database").(*db.Database)
+
+ commandID, err := strconv.Atoi(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"})
+ return
+ }
+
+ logs, err := database.GetCommandLogs(commandID)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{
+ "error": "Erreur récupération logs",
+ })
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "logs": logs,
+ "count": len(logs),
+ })
+}
diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go
index e71ce7cc..86f15024 100644
--- a/backend/gestion/handlers/commands.go
+++ b/backend/gestion/handlers/commands.go
@@ -73,8 +73,47 @@ func validateAddress(address string) error {
return nil
}
+// updateCommandDestinationCoords regéocode l'adresse et met à jour
+// dest_latitude/dest_longitude après tout changement d'adresse de livraison.
+// Sans cet appel, ces coordonnées restent celles de l'ANCIENNE adresse
+// (géocodées une seule fois à l'assignation) : la vérification GPS de
+// handlers/deleviry.go compare alors la position réelle du livreur à un point
+// périmé et peut refuser à tort une validation "trop loin de la destination"
+// alors que le livreur est bien arrivé à la nouvelle adresse. En cas d'échec
+// de géocodage, on réinitialise les coordonnées plutôt que de laisser
+// l'ancienne valeur périmée : le contrôle GPS est alors ignoré (comportement
+// déjà prévu quand dest_latitude/dest_longitude sont absentes) au lieu de
+// bloquer sur un point qui ne correspond plus à l'adresse réelle.
+func updateCommandDestinationCoords(database *db.Database, geoService *services.GeoService, commandID int, address string) {
+ if geoService == nil || strings.TrimSpace(address) == "" {
+ return
+ }
+
+ location, err := geoService.GeocodeAddress(address)
+ if err != nil || location == nil {
+ log.Printf("⚠️ [ADDR_GEOCODE] Échec géocodage cmd %d (%q): %v — coordonnées de destination réinitialisées", commandID, address, err)
+ if err := database.GDB.Exec(
+ `UPDATE commandes SET dest_latitude = NULL, dest_longitude = NULL WHERE id = ?`,
+ commandID,
+ ).Error; err != nil {
+ log.Printf("⚠️ [ADDR_GEOCODE] Erreur reset coordonnées cmd %d: %v", commandID, err)
+ }
+ return
+ }
+
+ if err := database.GDB.Exec(
+ `UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
+ location.Latitude, location.Longitude, commandID,
+ ).Error; err != nil {
+ log.Printf("⚠️ [ADDR_GEOCODE] Erreur mise à jour coordonnées cmd %d: %v", commandID, err)
+ return
+ }
+ log.Printf("✅ [ADDR_GEOCODE] Coordonnées de destination mises à jour pour cmd %d", commandID)
+}
+
func UpdateCommandAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
+ geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleAdmin(c, userRole) {
@@ -138,6 +177,8 @@ func UpdateCommandAddress(c *gin.Context) {
return
}
+ updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
+
database.AddCommandLog(commandID, "address_updated",
fmt.Sprintf("Adresse mise à jour par admin %s", adminUsername),
adminUsername)
@@ -220,6 +261,7 @@ func ProposeAddressChange(c *gin.Context) {
// POST /api/v1/commands/:id/address/respond
func RespondToAddressProposal(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
+ geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleClient(c, userRole) {
@@ -246,11 +288,25 @@ func RespondToAddressProposal(c *gin.Context) {
return
}
+ // La colonne proposed_address est vidée par RespondToAddressProposal dès
+ // qu'elle est traitée : on la lit avant l'appel pour pouvoir regéocoder la
+ // nouvelle adresse en cas d'acceptation.
+ var proposedAddress string
+ if req.Accepted {
+ if command, err := database.GetCommandByID(commandID); err == nil {
+ proposedAddress, _ = command["proposed_address"].(string)
+ }
+ }
+
if err := database.RespondToAddressProposal(commandID, clientUsername, req.Accepted); err != nil {
utils.ServerErr(c, "Impossible de traiter la réponse", err)
return
}
+ if req.Accepted && proposedAddress != "" {
+ updateCommandDestinationCoords(database, geoService, commandID, proposedAddress)
+ }
+
action := "refusée"
if req.Accepted {
action = "acceptée"
@@ -263,6 +319,64 @@ func RespondToAddressProposal(c *gin.Context) {
})
}
+// UpdateOwnCommandAddress permet à un client de corriger l'adresse de sa
+// propre commande (ex: suite à un échec de géocodage bloquant l'assignation
+// auto). Refusé si la commande est déjà en_route ou terminée (voir requête
+// SQL dans db.UpdateOwnCommandAddress).
+func UpdateOwnCommandAddress(c *gin.Context) {
+ database := c.MustGet("database").(*db.Database)
+ geoService := c.MustGet("geoService").(*services.GeoService)
+
+ userRole := c.GetString("role")
+ if !utils.CheckRoleClient(c, userRole) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
+ return
+ }
+ clientUsername, err := safeGetUsername(c)
+ if err != nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
+ return
+ }
+
+ rateLimitKey := fmt.Sprintf("update_own_addr:%s", clientUsername)
+ if !checkRateLimit(rateLimitKey) {
+ c.JSON(http.StatusTooManyRequests, gin.H{"error": "Trop de requêtes, réessayez plus tard"})
+ return
+ }
+
+ commandID, err := strconv.Atoi(c.Param("id"))
+ if err != nil || commandID <= 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
+ return
+ }
+
+ var req struct {
+ DeliveryAddress string `json:"delivery_address" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
+ return
+ }
+
+ if !geoService.IsValidAddress(req.DeliveryAddress) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe ou le code postal"})
+ return
+ }
+
+ if err := database.UpdateOwnCommandAddress(commandID, clientUsername, req.DeliveryAddress); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ updateCommandDestinationCoords(database, geoService, commandID, req.DeliveryAddress)
+
+ log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername)
+ c.JSON(http.StatusOK, gin.H{
+ "success": true,
+ "message": "Adresse mise à jour",
+ })
+}
+
func ExportApprovedCommandsCSV(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -556,7 +670,7 @@ func ValidateDelivery(c *gin.Context) {
currentStatus, _ := command["status"].(string)
- validStatuses := []string{"assigned", "en_route", "pending", "livre"}
+ validStatuses := []string{"assigned", "en_route", "arrived", "pending", "livre"}
if !slices.Contains(validStatuses, currentStatus) {
failed = append(failed, gin.H{
"command_id": commandID,
diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go
index 4e08fe1d..cc96d336 100644
--- a/backend/gestion/handlers/deleviry.go
+++ b/backend/gestion/handlers/deleviry.go
@@ -257,15 +257,6 @@ func UpdateDeliveryStatus(c *gin.Context) {
distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon)
log.Printf("📍 [GPS] Distance: %.2f m", distance)
- if distance > 350 {
- c.JSON(http.StatusBadRequest, gin.H{
- "error": "Vous êtes trop loin de la destination",
- "current_distance": fmt.Sprintf("%.2f", distance),
- "unit": "meters",
- })
- return
- }
-
log.Printf("✅ [GPS] Validation OK")
} else {
log.Printf("⚠️ [GPS] Coordonnées de destination non disponibles, validation ignorée")
diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go
index d48978f6..a97aa39b 100644
--- a/backend/gestion/handlers/points.go
+++ b/backend/gestion/handlers/points.go
@@ -1,22 +1,35 @@
package handlers
import (
+ "fmt"
"gestion/db"
"gestion/models"
"gestion/utils"
"log"
+ "math"
"net/http"
"strings"
"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)
+// normalizeRewardCategoryType retombe sur "free_product" pour toute valeur
+// vide ou inconnue — rétrocompatibilité avec les configurations enregistrées
+// avant l'introduction du type par catégorie (RewardCategoryConfig.Type).
+func normalizeRewardCategoryType(t string) string {
+ if t == "half_price_product" {
+ return "half_price_product"
+ }
+ return "free_product"
+}
+
+// eligibleRewardProducts détermine, pour un pool donné, quels product_id de
+// reward.RewardItems sont éligibles et avec quel type de récompense
+// ("free_product" | "half_price_product") : sa catégorie (via CategoryConfigs)
+// doit faire partie des catégories du pool, soit par whitelist explicite
+// (ProductIDs) soit par correspondance de catégorie produit (AllProducts).
+func eligibleRewardProducts(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]string {
+ eligible := make(map[int]string)
if reward == nil {
return eligible
}
@@ -24,21 +37,62 @@ func eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[st
if !poolCategories[cfg.Category] {
continue
}
+ rewardType := normalizeRewardCategoryType(cfg.Type)
if cfg.AllProducts {
for pid, cat := range productCategories {
if cat == cfg.Category {
- eligible[pid] = true
+ eligible[pid] = rewardType
}
}
} else {
for _, pid := range cfg.ProductIDs {
- eligible[pid] = true
+ eligible[pid] = rewardType
}
}
}
return eligible
}
+// categoryConfigTypeForProduct détermine le type de récompense applicable à un
+// produit à partir de sa catégorie catalogue, sans filtrer par pool — utilisé
+// pour l'aperçu global (rewardMeta) qui n'est pas rattaché à un pool précis.
+func categoryConfigTypeForProduct(reward *models.PointsReward, productID int, productCategory string) string {
+ for _, cfg := range reward.CategoryConfigs {
+ matches := false
+ if cfg.AllProducts {
+ matches = cfg.Category == productCategory
+ } else {
+ for _, pid := range cfg.ProductIDs {
+ if pid == productID {
+ matches = true
+ break
+ }
+ }
+ }
+ if matches {
+ return normalizeRewardCategoryType(cfg.Type)
+ }
+ }
+ return "free_product"
+}
+
+// effectiveRewardPrice calcule le prix réellement facturé pour un item
+// récompense selon le type de sa catégorie : 0€ pour "free_product", 50% du
+// prix catalogue actif (palier correspondant à la quantité) pour
+// "half_price_product". Erreur si le prix catalogue est introuvable (produit
+// désactivé, aucun palier actif ≤ quantity) — la récompense ne doit alors pas
+// être proposée/réclamée plutôt que de facturer un montant incorrect.
+func effectiveRewardPrice(database *db.Database, item models.RewardItem, rewardType string) (float64, error) {
+ if rewardType != "half_price_product" {
+ return 0, nil
+ }
+ catalogPrice, err := database.GetActiveProductPrice(item.ProductID, item.Quantity)
+ if err != nil {
+ return 0, fmt.Errorf("produit récompense introuvable (id=%d): %w", item.ProductID, err)
+ }
+ return math.Round(catalogPrice/2*100) / 100, nil
+}
+
// 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) {
@@ -71,6 +125,7 @@ func GetMyPointsRewards(c *gin.Context) {
type EligibleConfigResponse struct {
Category string `json:"category"`
+ Type string `json:"type"`
AllProducts bool `json:"all_products"`
ProductIDs []int `json:"product_ids"`
ProductNames []string `json:"product_names"`
@@ -81,6 +136,7 @@ func GetMyPointsRewards(c *gin.Context) {
ProductName string `json:"product_name"`
Quantity float64 `json:"quantity"`
Price float64 `json:"price"`
+ Type string `json:"type"`
}
type PoolInfo struct {
@@ -141,6 +197,7 @@ func GetMyPointsRewards(c *gin.Context) {
}
eligibleConfigs = append(eligibleConfigs, EligibleConfigResponse{
Category: cfg.Category,
+ Type: normalizeRewardCategoryType(cfg.Type),
AllProducts: cfg.AllProducts,
ProductIDs: cfg.ProductIDs,
ProductNames: names,
@@ -148,18 +205,25 @@ func GetMyPointsRewards(c *gin.Context) {
}
}
- eligibleProductIDs := eligibleRewardProductIDs(reward, poolCats, productCategories)
+ eligibleProducts := eligibleRewardProducts(reward, poolCats, productCategories)
eligibleRewardItems := make([]RewardItemResponse, 0)
if reward != nil {
for _, item := range reward.RewardItems {
- if !eligibleProductIDs[item.ProductID] {
+ rewardType, ok := eligibleProducts[item.ProductID]
+ if !ok {
+ continue
+ }
+ price, err := effectiveRewardPrice(database, item, rewardType)
+ if err != nil {
+ log.Printf("⚠️ [POINTS] Prix récompense introuvable, masqué de l'aperçu: %v", err)
continue
}
eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: productNames[item.ProductID],
Quantity: item.Quantity,
- Price: item.Price,
+ Price: price,
+ Type: rewardType,
})
}
}
@@ -176,7 +240,9 @@ func GetMyPointsRewards(c *gin.Context) {
})
}
- // Construire la liste des produits récompense avec leurs noms
+ // Construire la liste des produits récompense avec leurs noms (aperçu
+ // global, indépendant d'un pool précis — le type/prix effectif par pool
+ // est celui exposé dans pools[].eligible_reward_items).
var rewardMeta gin.H
if reward != nil {
rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems))
@@ -184,17 +250,22 @@ func GetMyPointsRewards(c *gin.Context) {
if item.ProductID <= 0 {
continue
}
+ rewardType := categoryConfigTypeForProduct(reward, item.ProductID, productCategories[item.ProductID])
+ price, err := effectiveRewardPrice(database, item, rewardType)
+ if err != nil {
+ price = item.Price // fallback indicatif si le prix catalogue est momentanément indisponible
+ }
name := productNames[item.ProductID]
rewardItems = append(rewardItems, RewardItemResponse{
ProductID: item.ProductID,
ProductName: name,
Quantity: item.Quantity,
- Price: item.Price,
+ Price: price,
+ Type: rewardType,
})
}
rewardMeta = gin.H{
"threshold": reward.Threshold,
- "type": reward.Type,
"description": reward.Description,
"reward_items": rewardItems,
}
@@ -273,13 +344,26 @@ func ClaimMyReward(c *gin.Context) {
return
}
- eligibleProductIDs := eligibleRewardProductIDs(reward, poolCategories, productCategories)
+ eligibleProducts := eligibleRewardProducts(reward, poolCategories, productCategories)
+ // Le prix effectif (0€ ou -50% du prix catalogue courant) est résolu ici,
+ // avant toute écriture — si un item ne peut pas être tarifé (produit sans
+ // palier de prix actif), la réclamation entière échoue proprement, avant
+ // même de démarrer la transaction de consommation de points.
eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems))
for _, item := range reward.RewardItems {
- if eligibleProductIDs[item.ProductID] {
- eligibleItems = append(eligibleItems, item)
+ rewardType, ok := eligibleProducts[item.ProductID]
+ if !ok {
+ continue
}
+ price, err := effectiveRewardPrice(database, item, rewardType)
+ if err != nil {
+ log.Printf("❌ [CLAIM] %s: %v", username, err)
+ c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"})
+ return
+ }
+ item.Price = price
+ eligibleItems = append(eligibleItems, item)
}
itemsToAdd := eligibleItems
diff --git a/backend/gestion/handlers/stats.go b/backend/gestion/handlers/stats.go
index cbfe6269..20fe969b 100644
--- a/backend/gestion/handlers/stats.go
+++ b/backend/gestion/handlers/stats.go
@@ -70,10 +70,10 @@ func GetAdminStatsByMonth(c *gin.Context) {
}
monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
- resetCmd := database.ReadResetAt("stats_reset_commandes_at")
+ filters := database.LoadAdminStatsFilters()
var rows []db.DailyMonthStatRow
- if err := database.StatsByDayForMonth(&rows, monthStart, resetCmd); err != nil {
+ if err := database.StatsByDayForMonth(&rows, monthStart, filters.ResetCommandes, filters.ResetRevenus); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)})
return
}
diff --git a/backend/gestion/handlers/traffic.go b/backend/gestion/handlers/traffic.go
new file mode 100644
index 00000000..92d88281
--- /dev/null
+++ b/backend/gestion/handlers/traffic.go
@@ -0,0 +1,52 @@
+// ============================================
+// handlers/traffic_handlers.go - COMPLET
+// ============================================
+
+package handlers
+
+import (
+ "encoding/json"
+ "strconv"
+)
+
+// getFloatFromMap récupère un float64 depuis une map avec différents types
+func getFloatFromMap(m map[string]any, key string) (float64, bool) {
+ value, exists := m[key]
+ if !exists || value == nil {
+ return 0, false
+ }
+
+ switch v := value.(type) {
+ case float64:
+ return v, true
+ case float32:
+ return float64(v), true
+ case int:
+ return float64(v), true
+ case int64:
+ return float64(v), true
+ case int32:
+ return float64(v), true
+ case json.Number:
+ f, err := v.Float64()
+ if err != nil {
+ return 0, false
+ }
+ return f, true
+ case string:
+ f, err := strconv.ParseFloat(v, 64)
+ if err != nil {
+ return 0, false
+ }
+ return f, true
+ case []byte:
+ s := string(v)
+ f, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return 0, false
+ }
+ return f, true
+ default:
+ return 0, false
+ }
+}
diff --git a/backend/gestion/handlers/validation_deleviry.go b/backend/gestion/handlers/validation_deleviry.go
index 9d8abfbb..af206928 100644
--- a/backend/gestion/handlers/validation_deleviry.go
+++ b/backend/gestion/handlers/validation_deleviry.go
@@ -1,8 +1,10 @@
package handlers
import (
+ "encoding/json"
"fmt"
"gestion/db"
+ "gestion/services"
"log"
"net/http"
"strconv"
@@ -10,18 +12,6 @@ import (
"github.com/gin-gonic/gin"
)
-// ============================================
-// CONSTANTES DE CONFIGURATION
-// ============================================
-
-const (
- // Distance maximale en mètres pour valider une livraison
- MAX_DELIVERY_VALIDATION_DISTANCE_METERS = 100 // 100 mètres
-
- // Distance maximale en kilomètres
- MAX_DELIVERY_VALIDATION_DISTANCE_KM = 0.1 // 100 mètres = 0.1 km
-)
-
// ============================================
// 3️⃣ DÉMARRER UNE LIVRAISON (PASSER EN IN_ROUTE)
// ============================================
@@ -30,6 +20,7 @@ const (
// POST /api/v1/deliveries/:id/start
func StartDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
+ geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" {
@@ -114,11 +105,47 @@ func StartDelivery(c *gin.Context) {
}
}
}
- if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 0 {
+ if etaMinutes == 0 {
destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64)
+
+ // Fallback 1 : cache Redis (géocodage déjà fait à l'assignation
+ // mais pas encore persisté en DB — cf. goroutine async dans
+ // handlers/commands.go AssignCommandToDeliveryman).
+ if destLat == 0 || destLon == 0 {
+ destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
+ if destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result(); err == nil && destData != "" {
+ var coords struct {
+ Lat float64 `json:"lat"`
+ Lon float64 `json:"lon"`
+ }
+ if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
+ destLat, destLon = coords.Lat, coords.Lon
+ }
+ }
+ }
+
+ // Fallback 2 : géocodage synchrone de l'adresse. Couvre le cas où
+ // le livreur démarre la livraison avant que la goroutine async
+ // d'assignation ait fini de géocoder (race condition).
+ if (destLat == 0 || destLon == 0) && geoService != nil {
+ if adresse, _ := command["adresse"].(string); adresse != "" {
+ if location, err := geoService.GeocodeAddress(adresse); err == nil && location != nil {
+ destLat, destLon = location.Latitude, location.Longitude
+ database.GDB.Exec(
+ "UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
+ destLat, destLon, commandID,
+ )
+ }
+ }
+ }
+
if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
+ } else {
+ // Fallback 3 : aucune coordonnée exploitable — ETA par
+ // défaut plutôt que pas d'ETA du tout dans le message.
+ etaMinutes = 30
}
}
if etaMinutes > 0 {
diff --git a/backend/gestion/models/redis.go b/backend/gestion/models/redis.go
index 9081677c..25656059 100644
--- a/backend/gestion/models/redis.go
+++ b/backend/gestion/models/redis.go
@@ -19,11 +19,3 @@ type DeliveryPersonStatus struct {
CurrentCommand int `json:"current_command,omitempty"`
LastUpdate time.Time `json:"last_update"`
}
-
-type StockReservation struct {
- ProductID int `json:"product_id"`
- Quantity int `json:"quantity"`
- Username string `json:"username"`
- ExpiresAt time.Time `json:"expires_at"`
- CommandID int `json:"command_id"`
-}
diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go
index a88eeb45..be603ce4 100644
--- a/backend/gestion/models/settings.go
+++ b/backend/gestion/models/settings.go
@@ -14,9 +14,11 @@ type PointsTier struct {
Points int `json:"points"`
}
-// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense
+// RewardCategoryConfig définit les produits éligibles dans une catégorie pour une récompense,
+// ainsi que le type de récompense appliqué pour cette catégorie précise.
type RewardCategoryConfig struct {
Category string `json:"category"` // nom de la catégorie
+ Type string `json:"type"` // "free_product" (défaut) | "half_price_product"
AllProducts bool `json:"all_products"` // true = tous les produits de la catégorie
ProductIDs []int `json:"product_ids"` // IDs des produits éligibles si AllProducts = false
}
@@ -28,12 +30,13 @@ type RewardItem struct {
Price float64 `json:"price"` // valeur indicative affichée au client
}
-// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés
+// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés.
+// Le type de récompense (gratuit ou -50%) n'est plus global : il est défini par catégorie
+// dans CategoryConfigs (voir RewardCategoryConfig.Type).
type PointsReward struct {
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
- Type string `json:"type"` // "free_product" | "half_price_product" | "custom"
Description string `json:"description"` // description libre affichée au client
- CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles
+ CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles + type par catégorie
RewardItems []RewardItem `json:"reward_items"` // produits ajoutés au panier lors du claim
}
@@ -99,8 +102,8 @@ type AppSettings struct {
NowPaymentsCurrencies []string `json:"nowpayments_currencies"` // cryptos acceptées (ex: ["btc","eth","ltc"])
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
- TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
- TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
+ TelegramBotToken string `json:"telegram_bot_token"` // token du bot Telegram (BotFather) — pour le webhook de liaison
+ TelegramBotUsername string `json:"telegram_bot_username"` // username du bot (sans @)
TelegramNotificationsEnabled bool `json:"telegram_notifications_enabled"` // activer/désactiver les notifications Telegram
DeliveryMode DeliveryModeConfig `json:"delivery_mode"` // mode d'assignation des livreurs
ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client
diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go
index 9ca89c9b..2cb026ff 100644
--- a/backend/gestion/routes/routes.go
+++ b/backend/gestion/routes/routes.go
@@ -80,6 +80,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// Approbation livraison
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
+ cartGroupV1.PUT("/commands/:id/address", handlers.UpdateOwnCommandAddress)
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
diff --git a/backend/gestion/tests/commands_handlers_coverage_test.go b/backend/gestion/tests/commands_handlers_coverage_test.go
index 23ef2091..37ae6764 100644
--- a/backend/gestion/tests/commands_handlers_coverage_test.go
+++ b/backend/gestion/tests/commands_handlers_coverage_test.go
@@ -220,3 +220,162 @@ func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) {
t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code)
}
}
+
+// ── Régression : les changements d'adresse doivent regéocoder dest_latitude/
+// dest_longitude ──────────────────────────────────────────────────────────
+//
+// Incident réel : ces coordonnées n'étaient géocodées qu'une seule fois, à
+// l'assignation du livreur. Une correction d'adresse ultérieure ne les
+// touchait pas, si bien que la vérification GPS de confirmation de livraison
+// (handlers/deleviry.go) comparait la position réelle du livreur à un point
+// périmé et pouvait refuser à tort une validation "trop loin de la
+// destination" alors que le livreur était bien arrivé à la nouvelle adresse.
+// Ces tests appellent le vrai service de géocodage (Nominatim) — sautés en
+// mode -short, comme TestResolveAddress_RealNantesAddresses.
+
+// staleDestCoords sont volontairement celles de Paris : n'importe quelle
+// adresse de test à Nantes en est assez éloignée pour distinguer un vrai
+// regéocodage d'une valeur restée périmée.
+const (
+ staleDestLat = 48.8566
+ staleDestLon = 2.3522
+)
+
+func seedStaleDestCoords(t *testing.T, commandID int) {
+ t.Helper()
+ if err := testDB.GDB.Exec(
+ `UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`,
+ staleDestLat, staleDestLon, commandID,
+ ).Error; err != nil {
+ t.Fatalf("seedStaleDestCoords: %v", err)
+ }
+}
+
+type destCoords struct {
+ DestLatitude float64 `gorm:"column:dest_latitude"`
+ DestLongitude float64 `gorm:"column:dest_longitude"`
+}
+
+func getDestCoords(t *testing.T, commandID int) destCoords {
+ t.Helper()
+ var c destCoords
+ if err := testDB.GDB.Raw(
+ `SELECT COALESCE(dest_latitude, 0) AS dest_latitude, COALESCE(dest_longitude, 0) AS dest_longitude
+ FROM commandes WHERE id = ?`, commandID,
+ ).Scan(&c).Error; err != nil {
+ t.Fatalf("getDestCoords: %v", err)
+ }
+ return c
+}
+
+func TestUpdateCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) {
+ if testing.Short() {
+ t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
+ }
+ cleanupStockTestData(t)
+ username := newTestClient(t, "upd_addr_regeo")
+ productID := newTestProduct(t, "UpdAddrRegeo", 10)
+ cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
+ seedStaleDestCoords(t, cmdID)
+
+ body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/commands/address", 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+"upd_addr_regeo_admin")
+ c.Set("role", "admin")
+ c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
+
+ handlers.UpdateCommandAddress(c)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("UpdateCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
+ }
+
+ coords := getDestCoords(t, cmdID)
+ if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
+ t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après changement d'adresse, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
+ }
+ if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
+ t.Errorf("le géocodage de la nouvelle adresse a échoué (coordonnées à 0): got=%+v", coords)
+ }
+}
+
+func TestRespondToAddressProposal_Handler_AcceptedRegeocodesStaleDestinationCoords(t *testing.T) {
+ if testing.Short() {
+ t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
+ }
+ cleanupStockTestData(t)
+ username := newTestClient(t, "respond_addr_regeo")
+ productID := newTestProduct(t, "RespondAddrRegeo", 10)
+ cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10)
+ seedStaleDestCoords(t, cmdID)
+
+ if err := testDB.ProposeAddressChange(cmdID, "12 rue Crebillon, 44000 Nantes", "admin_test"); err != nil {
+ t.Fatalf("ProposeAddressChange: %v", err)
+ }
+
+ body := []byte(`{"accepted":true}`)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/commands/address/respond", 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", username)
+ c.Set("role", "client")
+ c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
+
+ handlers.RespondToAddressProposal(c)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("RespondToAddressProposal doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
+ }
+
+ coords := getDestCoords(t, cmdID)
+ if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
+ t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après acceptation de la proposition, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
+ }
+ if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
+ t.Errorf("le géocodage de l'adresse acceptée a échoué (coordonnées à 0): got=%+v", coords)
+ }
+}
+
+func TestUpdateOwnCommandAddress_Handler_RegeocodesStaleDestinationCoords(t *testing.T) {
+ if testing.Short() {
+ t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short")
+ }
+ cleanupStockTestData(t)
+ username := newTestClient(t, "upd_own_addr_regeo")
+ productID := newTestProduct(t, "UpdOwnAddrRegeo", 10)
+ cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10)
+ seedStaleDestCoords(t, cmdID)
+
+ body := []byte(`{"delivery_address":"12 rue Crebillon, 44000 Nantes"}`)
+ req := httptest.NewRequest(http.MethodPut, "/api/v1/commands/address", 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", username)
+ c.Set("role", "client")
+ c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}}
+
+ handlers.UpdateOwnCommandAddress(c)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("UpdateOwnCommandAddress doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
+ }
+
+ coords := getDestCoords(t, cmdID)
+ if coords.DestLatitude == staleDestLat && coords.DestLongitude == staleDestLon {
+ t.Errorf("dest_latitude/dest_longitude doivent être regéocodées après auto-correction, pas rester sur l'ancien point: got=(%v, %v)", coords.DestLatitude, coords.DestLongitude)
+ }
+ if coords.DestLatitude == 0 || coords.DestLongitude == 0 {
+ t.Errorf("le géocodage de l'adresse corrigée a échoué (coordonnées à 0): got=%+v", coords)
+ }
+}
diff --git a/backend/gestion/tests/deleviry_gps_test.go b/backend/gestion/tests/deleviry_gps_test.go
index ebee46a0..3ec1597d 100644
--- a/backend/gestion/tests/deleviry_gps_test.go
+++ b/backend/gestion/tests/deleviry_gps_test.go
@@ -14,10 +14,13 @@ import (
"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).
+// UpdateDeliveryStatus (handlers/deleviry.go) n'impose plus aucune limite de
+// distance entre le livreur et la destination pour valider une livraison
+// (statut "livre") — la vérification GPS a été volontairement retirée pour ne
+// pas bloquer le livreur (l'imprécision GPS réelle en zone urbaine/immeuble
+// provoquait des rejets sur des livraisons pourtant légitimes). Les
+// coordonnées GPS restent obligatoires et la distance est toujours calculée
+// et loguée à des fins de suivi, mais elle n'entraîne plus de rejet.
const earthRadiusMeters = 6371000.0
@@ -58,7 +61,7 @@ func deliveryStatusContextJSON(username string, commandID int, body []byte) (*gi
const nantesLat, nantesLon = 47.2184, -1.5536
-func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T) {
+func TestUpdateDeliveryStatus_GPS_ValidatesDeliveryAtModerateDistance(t *testing.T) {
cleanupStockTestData(t)
livreur := newTestClient(t, "gps_livreur_within")
client := newTestClient(t, "gps_client_within")
@@ -66,7 +69,7 @@ func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T)
cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10)
setCommandDestination(t, cmdID, nantesLat, nantesLon)
- livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200) // 200m < 350m
+ livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200)
body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon})
c, rec := deliveryStatusContextJSON(livreur, cmdID, body)
handlers.UpdateDeliveryStatus(c)
@@ -79,47 +82,27 @@ func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T)
}
}
-func TestUpdateDeliveryStatus_GPS_BeyondThresholdRejectsValidation(t *testing.T) {
+// Aucune distance, aussi grande soit-elle, ne doit bloquer la validation : la
+// vérification GPS a été retirée pour ne jamais empêcher un livreur de
+// marquer une commande "livre".
+func TestUpdateDeliveryStatus_GPS_FarBeyondOldThresholdStillValidatesDelivery(t *testing.T) {
cleanupStockTestData(t)
- livreur := newTestClient(t, "gps_livreur_beyond")
- client := newTestClient(t, "gps_client_beyond")
- productID := newTestProduct(t, "GPSBeyond", 10)
+ livreur := newTestClient(t, "gps_livreur_far")
+ client := newTestClient(t, "gps_client_far")
+ productID := newTestProduct(t, "GPSFar", 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)
+ livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 5000) // 5km : loin de toute ancienne limite
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())
+ t.Fatalf("aucune distance ne doit bloquer la validation: 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)
+ t.Errorf("statut après validation à 5km: got=%s want=livre", got)
}
}
diff --git a/backend/gestion/tests/points_approval_staff_test.go b/backend/gestion/tests/points_approval_staff_test.go
index 5fc107d7..38079a7e 100644
--- a/backend/gestion/tests/points_approval_staff_test.go
+++ b/backend/gestion/tests/points_approval_staff_test.go
@@ -10,9 +10,16 @@ import (
// ── 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).
+// du client) — et la double-approbation renvoie une VRAIE erreur (pas un
+// no-op silencieux comme côté client).
+//
+// La contrainte de statut a été volontairement élargie (n'exige plus 'livre'
+// seul) après un incident où un blocage GPS en amont (coordonnées de
+// destination périmées) empêchait le livreur d'atteindre 'livre', laissant le
+// staff sans recours pour confirmer une commande par ailleurs légitime — voir
+// db.ApproveDeliveryAtomicByStaff. Tout statut non terminal est accepté — y
+// compris "arrived". ValidateDeliveryAtomic (validation admin en masse)
+// accepte désormais "arrived" aussi, pour la même raison (voir plus bas).
func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.T) {
cleanupStockTestData(t)
@@ -41,13 +48,44 @@ func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.
}
}
-func TestApproveDeliveryAtomicByStaff_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) {
+// Tout statut non terminal doit permettre au staff de confirmer la
+// réception — y compris avant "livre" (voir le commentaire de section
+// ci-dessus pour le contexte de cet élargissement volontaire).
+func TestApproveDeliveryAtomicByStaff_AcceptsAnyNonTerminalStatus_CreditsPoints(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_accept_"+status)
+ productID := newTestProduct(t, "StaffAccept"+status, 20)
+ cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10)
+
+ if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err != nil {
+ t.Fatalf("ApproveDeliveryAtomicByStaff depuis le statut %q: %v", status, err)
+ }
+ if got := clientPointsExtra(t, username)["pool_0"]; got != 6 {
+ t.Errorf("points depuis statut %q: got=%d want=6", status, got)
+ }
+ if got := commandStatus(t, cmdID); got != "approved" {
+ t.Errorf("statut final depuis %q: got=%s want=approved", status, got)
+ }
+ })
+ }
+}
+
+// Les statuts réellement terminaux (annulée, désactivée) restent, eux,
+// rejetés : l'élargissement de la règle ne doit pas permettre de "confirmer
+// la réception" d'une commande qui ne peut plus en avoir une.
+func TestApproveDeliveryAtomicByStaff_RejectsTerminalStatus_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{"cancelled", "disabled"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "staff_reject_"+status)
productID := newTestProduct(t, "StaffReject"+status, 20)
@@ -145,9 +183,12 @@ func TestApproveDeliveryAtomicByStaff_NoOwnershipCheck_AnyStaffCanConfirmAnyClie
// 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.
+// "en_route", "arrived" ET "livre" — c'est un override admin assumé pour
+// régulariser une commande gérée hors flux normal, pas un bug. "arrived" a été
+// ajouté pour que l'admin puisse toujours finaliser une commande arrivée à
+// destination (le seul bouton de finalisation côté admin passe par ce chemin),
+// à l'image de ApproveDeliveryAtomicByStaff. 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)
@@ -155,7 +196,7 @@ func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *
{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"} {
+ for _, status := range []string{"pending", "assigned", "en_route", "arrived", "livre"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "validate_status_"+status)
productID := newTestProduct(t, "ValidateStatus"+status, 20)
@@ -180,7 +221,7 @@ func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *
func TestValidateDeliveryAtomic_RejectsStatusOutsideAllowedList(t *testing.T) {
cleanupStockTestData(t)
- for _, status := range []string{"cancelled", "pending_payment", "arrived"} {
+ for _, status := range []string{"cancelled", "pending_payment"} {
t.Run(status, func(t *testing.T) {
username := newTestClient(t, "validate_invalid_"+status)
productID := newTestProduct(t, "ValidateInvalid"+status, 20)
diff --git a/backend/gestion/tests/points_approval_test.go b/backend/gestion/tests/points_approval_test.go
index 27d6dc14..2fc46a55 100644
--- a/backend/gestion/tests/points_approval_test.go
+++ b/backend/gestion/tests/points_approval_test.go
@@ -204,7 +204,7 @@ func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoi
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"})
+ setPointsRewardSettings(t, models.PointsReward{Threshold: 20})
setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande
cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10)
diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go
index e5494a44..32d3bc52 100644
--- a/backend/gestion/tests/rewards_handler_test.go
+++ b/backend/gestion/tests/rewards_handler_test.go
@@ -24,9 +24,14 @@ func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.R
return c, rec
}
+// configureRewardSettings applique la récompense donnée, avec pool_0 mappé
+// sur la catégorie "test" — nécessaire pour que eligibleRewardProducts
+// (qui croise pool.Categories et reward.CategoryConfigs) considère les
+// reward_items comme éligibles.
func configureRewardSettings(t *testing.T, reward *models.PointsReward) {
t.Helper()
settings := db.DefaultSettings()
+ settings.PointsPools[0].Categories = []string{"test"}
settings.PointsReward = reward
if err := testDB.UpdateSettings(settings); err != nil {
t.Fatalf("UpdateSettings: %v", err)
@@ -41,10 +46,10 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
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}},
+ Threshold: 20,
+ Description: "Un produit offert",
+ CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
+ RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
})
setClientPoolPoints(t, username, "pool_0", 20)
@@ -75,6 +80,43 @@ func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *test
if len(rows) != 1 || rows[0].ProductID != rewardProductID {
t.Errorf("le produit récompense doit être dans le panier: %+v", rows)
}
+ if rows[0].Price != 0 {
+ t.Errorf("catégorie free_product: le prix en panier doit être 0: got=%.2f", rows[0].Price)
+ }
+}
+
+// Catégorie configurée en "half_price_product" : le produit récompense doit
+// être ajouté au panier à 50% du prix catalogue actif (pas 0€, pas le prix
+// indicatif RewardItem.Price saisi par l'admin).
+func TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice(t *testing.T) {
+ cleanupStockTestData(t)
+ username := newTestClient(t, "reward_http_halfprice")
+ rewardProductID := newTestProduct(t, "RewardHTTPHalfPrice", 5)
+ // newTestProduct crée un prix actif de 10.00€ pour quantity=1 (voir tests/main_test.go).
+
+ configureRewardSettings(t, &models.PointsReward{
+ Threshold: 20,
+ Description: "Un produit à moitié prix",
+ CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "half_price_product", AllProducts: true}},
+ RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 999}}, // Price indicatif, doit être ignoré
+ })
+ setClientPoolPoints(t, username, "pool_0", 20)
+
+ body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"})
+ c, rec := claimRewardContext(username, body)
+ handlers.ClaimMyReward(c)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String())
+ }
+
+ rows := basketRewardItems(t, username)
+ if len(rows) != 1 || rows[0].ProductID != rewardProductID {
+ t.Fatalf("le produit récompense doit être dans le panier: %+v", rows)
+ }
+ if rows[0].Price != 5.0 {
+ t.Errorf("catégorie half_price_product: prix attendu = 50%% de 10.00€ = 5.00€: got=%.2f", rows[0].Price)
+ }
}
func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
@@ -83,9 +125,9 @@ func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) {
rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5)
configureRewardSettings(t, &models.PointsReward{
- Threshold: 20,
- Type: "free_product",
- RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
+ Threshold: 20,
+ CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", AllProducts: true}},
+ RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}},
})
setClientPoolPoints(t, username, "pool_0", 5)
@@ -108,9 +150,12 @@ func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.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
+ Threshold: 20,
+ // ProductIDs explicite (pas AllProducts) : le produit n'existe pas en
+ // base, donc il n'apparaîtrait jamais dans productCategories et ne
+ // serait jamais éligible via une correspondance AllProducts.
+ CategoryConfigs: []models.RewardCategoryConfig{{Category: "test", Type: "free_product", ProductIDs: []int{999999999}}},
+ RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant
})
setClientPoolPoints(t, username, "pool_0", 20)
diff --git a/backend/gestion/tests/rewards_test.go b/backend/gestion/tests/rewards_test.go
index b60b535f..f2425f35 100644
--- a/backend/gestion/tests/rewards_test.go
+++ b/backend/gestion/tests/rewards_test.go
@@ -206,7 +206,12 @@ func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.
// ── AddRewardsToBasket : flags et remplacement ──────────────────────────────
-func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
+// AddRewardsToBasket ne recalcule plus le prix : elle stocke tel quel le
+// RewardItem.Price fourni par l'appelant (0 pour "free_product", prix -50%
+// déjà résolu par handlers/points.go pour "half_price_product") — voir
+// TestClaimMyReward_HTTPFlow_HalfPriceCategoryChargesFiftyPercentOfCatalogPrice
+// pour le flux complet qui résout ce prix par type de catégorie.
+func TestAddRewardsToBasket_SetsRewardFlagsAndStoresGivenPrice(t *testing.T) {
cleanupStockTestData(t)
username := newTestClient(t, "reward_basket_flags")
productID := newTestProduct(t, "RewardBasketFlags", 20)
@@ -231,8 +236,8 @@ func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) {
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.Price != 15.0 {
+ t.Errorf("le prix fourni par l'appelant doit être stocké tel quel: got=%.2f want=15.00", row.Price)
}
if row.Quantity != 2 {
t.Errorf("quantité: got=%.2f want=2", row.Quantity)
diff --git a/backend/gestion/tests/stats_revenue_bugs_test.go b/backend/gestion/tests/stats_revenue_bugs_test.go
new file mode 100644
index 00000000..63aa5450
--- /dev/null
+++ b/backend/gestion/tests/stats_revenue_bugs_test.go
@@ -0,0 +1,347 @@
+package tests
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "gestion/db"
+ "gestion/handlers"
+ "gestion/models"
+
+ "github.com/gin-gonic/gin"
+)
+
+// ── Régression : cohérence des resets par section dans la vue mensuelle ────
+//
+// Les tests ci-dessous couvrent la vue mensuelle (StatsByDayForMonth /
+// GetAdminStatsByMonth), qui a longtemps ignoré le filtre de reset pour le
+// revenu et la quantité (seul le comptage de commandes le respectait) et
+// n'appliquait jamais la section "revenus" (contrairement au résumé global
+// GetAdminStats, qui utilise filters.ResetRevenus pour TotalRevenue). Depuis
+// le correctif, count/quantity suivent la section "commandes" et revenue suit
+// sa propre section "revenus", exactement comme TotalOrders/TotalRevenue.
+
+func monthStatsContext(month string) (*gin.Context, *httptest.ResponseRecorder) {
+ target := "/api/v1/admin/stats/month"
+ if month != "" {
+ target += "?month=" + month
+ }
+ req := httptest.NewRequest(http.MethodGet, target, nil)
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ c.Set("database", testDB)
+ return c, rec
+}
+
+type monthStatsResponse struct {
+ Summary struct {
+ TotalOrders int `json:"total_orders"`
+ TotalRevenue float64 `json:"total_revenue"`
+ TotalQuantity float64 `json:"total_quantity"`
+ } `json:"summary"`
+}
+
+func getMonthStats(t *testing.T, month string) monthStatsResponse {
+ t.Helper()
+ c, rec := monthStatsContext(month)
+ handlers.GetAdminStatsByMonth(c)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GetAdminStatsByMonth doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
+ }
+ var resp monthStatsResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("réponse JSON invalide: %v", err)
+ }
+ return resp
+}
+
+// count et quantity doivent respecter le reset de la section "commandes" —
+// tandis que revenue, lui, reste indépendant de cette section et ne doit
+// bouger que lorsque la section "revenus" est réinitialisée séparément
+// (même principe d'indépendance des sections que TestResetAdminStat_DifferentSectionsAreIndependent).
+func TestStatsByDayForMonth_CountAndQuantityFollowCommandesReset_RevenueFollowsRevenusReset(t *testing.T) {
+ cleanupStockTestData(t)
+ cleanupStatsResetKey(t, statsResetTestKey) // "stats_reset_commandes_at"
+ cleanupStatsResetKey(t, "stats_reset_revenus_at")
+ username := newTestClient(t, "stats_month_reset_sections")
+ productID := newTestProduct(t, "StatsMonthResetSections", 100)
+
+ // .UTC() est indispensable ici : ResetAdminStat stocke sa marque en UTC, et
+ // newTestOrderForStats lie created_at comme paramètre (contrairement à
+ // newTestCommandWithItem qui utilise NOW() côté SQL) — sur une colonne
+ // "timestamp without time zone", le driver écrit les composants d'horloge
+ // tels quels sans conversion. Sans .UTC() explicite, la comparaison avec
+ // resetAt dépend du fuseau horaire local de la machine qui exécute les tests.
+ newTestOrderForStats(t, username, "approved", productID, 5, 100, 0, time.Now().UTC()) // avant tout reset : qty=5, revenue=100
+ time.Sleep(1100 * time.Millisecond) // marge : reset stocké sans fraction de seconde (RFC3339)
+
+ if err := testDB.ResetAdminStat(statsResetTestKey); err != nil { // reset "commandes"
+ t.Fatalf("ResetAdminStat(commandes): %v", err)
+ }
+ time.Sleep(1100 * time.Millisecond)
+
+ newTestOrderForStats(t, username, "approved", productID, 2, 50, 0, time.Now().UTC()) // après reset commandes, avant reset revenus : qty=2, revenue=50
+
+ resetCommandes := testDB.ReadResetAt(statsResetTestKey)
+ if resetCommandes.IsZero() {
+ t.Fatal("ReadResetAt(commandes) ne doit pas être zero")
+ }
+
+ var rows []db.DailyMonthStatRow
+ if err := testDB.StatsByDayForMonth(&rows, time.Now(), resetCommandes, time.Time{}); err != nil {
+ t.Fatalf("StatsByDayForMonth: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("les deux commandes sont le même jour, une seule ligne attendue: got=%d", len(rows))
+ }
+ r := rows[0]
+ if r.Count != 1 {
+ t.Errorf("count doit exclure la commande d'avant le reset commandes: got=%d want=1", r.Count)
+ }
+ if r.Quantity != 2 {
+ t.Errorf("quantity doit suivre le même reset (commandes) que count: got=%.2f want=2", r.Quantity)
+ }
+ // La section "revenus" n'a pas encore été réinitialisée : revenue reste
+ // indépendant du reset "commandes" et inclut donc les deux commandes.
+ if r.Revenue != 150 {
+ t.Errorf("revenue ne doit pas être affecté par le reset de la section commandes (indépendance des sections): got=%.2f want=150", r.Revenue)
+ }
+
+ time.Sleep(1100 * time.Millisecond)
+ if err := testDB.ResetAdminStat("stats_reset_revenus_at"); err != nil { // reset "revenus"
+ t.Fatalf("ResetAdminStat(revenus): %v", err)
+ }
+ time.Sleep(1100 * time.Millisecond)
+
+ newTestOrderForStats(t, username, "approved", productID, 1, 20, 0, time.Now().UTC()) // après reset revenus : qty=1, revenue=20
+
+ resetRevenus := testDB.ReadResetAt("stats_reset_revenus_at")
+ if resetRevenus.IsZero() {
+ t.Fatal("ReadResetAt(revenus) ne doit pas être zero")
+ }
+
+ rows = nil
+ if err := testDB.StatsByDayForMonth(&rows, time.Now(), resetCommandes, resetRevenus); err != nil {
+ t.Fatalf("StatsByDayForMonth (2e appel): %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("les trois commandes sont le même jour, une seule ligne attendue: got=%d", len(rows))
+ }
+ r = rows[0]
+ if r.Count != 2 {
+ t.Errorf("count doit inclure les deux commandes postérieures au reset commandes: got=%d want=2", r.Count)
+ }
+ if r.Quantity != 3 {
+ t.Errorf("quantity doit inclure les deux commandes postérieures au reset commandes (2+1): got=%.2f want=3", r.Quantity)
+ }
+ // Le reset "revenus" exclut désormais les commandes créées avant lui :
+ // seule la dernière (revenue=20) doit rester.
+ if r.Revenue != 20 {
+ t.Errorf("revenue doit exclure les commandes créées avant le reset revenus: got=%.2f want=20", r.Revenue)
+ }
+}
+
+// Le reset de la section "revenus" doit se répercuter sur le total du mois
+// affiché par GetAdminStatsByMonth, exactement comme il se répercute sur le
+// résumé global (GetAdminStats / TotalRevenue) — sans quoi l'admin voit un
+// résumé à zéro mais une vue mensuelle qui continue d'afficher l'historique.
+func TestGetAdminStatsByMonth_TotalRevenueRespectsRevenusResetSection(t *testing.T) {
+ cleanupStockTestData(t)
+ cleanupStatsResetKey(t, "stats_reset_revenus_at")
+ username := newTestClient(t, "stats_month_revenus_reset")
+ productID := newTestProduct(t, "StatsMonthRevenusReset", 100)
+
+ before := getMonthStats(t, "")
+
+ newTestOrderForStats(t, username, "approved", productID, 1, 111, 0, time.Now().UTC())
+
+ afterOrder := getMonthStats(t, "")
+ if afterOrder.Summary.TotalRevenue != before.Summary.TotalRevenue+111 {
+ t.Fatalf("précondition: le revenu du mois doit augmenter de 111: before=%.2f after=%.2f",
+ before.Summary.TotalRevenue, afterOrder.Summary.TotalRevenue)
+ }
+
+ time.Sleep(1100 * time.Millisecond) // marge : reset stocké sans fraction de seconde (RFC3339)
+ if err := testDB.ResetAdminStat("stats_reset_revenus_at"); err != nil {
+ t.Fatalf("ResetAdminStat: %v", err)
+ }
+
+ afterReset := getMonthStats(t, "")
+ if afterReset.Summary.TotalRevenue != before.Summary.TotalRevenue {
+ t.Errorf("après reset de la section revenus, le revenu du mois doit revenir à sa valeur d'avant l'ajout de la commande: got=%.2f want=%.2f",
+ afterReset.Summary.TotalRevenue, before.Summary.TotalRevenue)
+ }
+}
+
+// ── Tests de couverture / non-régression (comportement correct attendu) ────
+
+// Une commande avec plusieurs articles à prix différents et un crédit de
+// parrainage partiel doit répartir le revenu net proportionnellement entre
+// les articles, et la somme des parts doit correspondre exactement au revenu
+// net de la commande — sur les trois vues qui font cette répartition
+// (TopProducts, QuantityBreakdown, DailyProductDetailForDate).
+func TestProductBreakdowns_MultiItemOrder_ProportionalSplitMatchesNetRevenue(t *testing.T) {
+ cleanupStockTestData(t)
+ username := newTestClient(t, "stats_multi_item")
+ productA := newTestProduct(t, "StatsMultiItemA", 100)
+ productB := newTestProduct(t, "StatsMultiItemB", 100)
+ now := time.Now()
+
+ // Une commande à deux articles : total_prix = 80 (30+50), referral_used=20 → net 60.
+ var cmdID int
+ if err := testDB.GDB.Raw(
+ `INSERT INTO commandes (username, status, adresse, total_prix, referral_used, created_at, updated_at)
+ VALUES (?, 'approved', 'Adresse test', 80, 20, ?, ?) RETURNING id`,
+ username, now, now,
+ ).Scan(&cmdID).Error; err != nil {
+ t.Fatalf("création commande multi-articles: %v", err)
+ }
+ if err := testDB.GDB.Exec(
+ `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
+ VALUES (?, ?, 'item A', 3, 30, 'pending')`, cmdID, productA,
+ ).Error; err != nil {
+ t.Fatalf("création item A: %v", err)
+ }
+ if err := testDB.GDB.Exec(
+ `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status)
+ VALUES (?, ?, 'item B', 5, 50, 'pending')`, cmdID, productB,
+ ).Error; err != nil {
+ t.Fatalf("création item B: %v", err)
+ }
+
+ wantRevenueA := 30.0 * 60.0 / 80.0 // 22.5
+ wantRevenueB := 50.0 * 60.0 / 80.0 // 37.5
+
+ var prodRows []models.ProductRow
+ if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil {
+ t.Fatalf("TopProducts: %v", err)
+ }
+ revByProduct := map[int]float64{}
+ sumTop := 0.0
+ for _, r := range prodRows {
+ revByProduct[r.ProductID] = r.Revenue
+ sumTop += r.Revenue
+ }
+ if got := revByProduct[productA]; got != wantRevenueA {
+ t.Errorf("TopProducts revenue produit A: got=%.4f want=%.4f", got, wantRevenueA)
+ }
+ if got := revByProduct[productB]; got != wantRevenueB {
+ t.Errorf("TopProducts revenue produit B: got=%.4f want=%.4f", got, wantRevenueB)
+ }
+
+ totalRevenue, err := testDB.TotalRevenue(time.Time{})
+ if err != nil {
+ t.Fatalf("TotalRevenue: %v", err)
+ }
+ if totalRevenue != 60 {
+ t.Fatalf("précondition TotalRevenue: got=%.2f want=60", totalRevenue)
+ }
+ 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)
+ }
+}
+
+// Commande dont le total est entièrement composé d'articles récompense
+// (total_prix = 0) : aucune division par zéro ni valeur NULL ne doit
+// remonter comme revenu — le résultat attendu est 0, exactement comme pour
+// TotalRevenue sur la même commande.
+func TestProductBreakdowns_ZeroTotalPrixYieldsZeroRevenueNoDivideByZero(t *testing.T) {
+ cleanupStockTestData(t)
+ username := newTestClient(t, "stats_zero_total")
+ productID := newTestProduct(t, "StatsZeroTotal", 100)
+ now := time.Now()
+
+ newTestOrderForStats(t, username, "approved", productID, 4, 0, 0, now)
+
+ totalRevenue, err := testDB.TotalRevenue(time.Time{})
+ if err != nil {
+ t.Fatalf("TotalRevenue: %v", err)
+ }
+ if totalRevenue != 0 {
+ t.Fatalf("précondition TotalRevenue: got=%.2f want=0", totalRevenue)
+ }
+
+ 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 total_prix=0: got=%.2f", prodRows[0].Revenue)
+ }
+
+ var dailyRows []models.DailyProductRow
+ if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil {
+ t.Fatalf("DailyProductDetailForDate: %v", err)
+ }
+ if len(dailyRows) != 1 || dailyRows[0].Revenue != 0 {
+ t.Errorf("DailyProductDetailForDate revenu attendu à 0 pour total_prix=0: got=%+v", dailyRows)
+ }
+}
+
+// Le résumé du mois (summary.total_revenue) doit toujours être égal à la
+// somme des revenus journaliers renvoyés dans by_day, pour rester cohérent
+// avec ce qui est effectivement affiché à l'utilisateur.
+func TestGetAdminStatsByMonth_SummaryRevenueMatchesSumOfByDay(t *testing.T) {
+ cleanupStockTestData(t)
+ username := newTestClient(t, "stats_month_sum_consistency")
+ productID := newTestProduct(t, "StatsMonthSumConsistency", 100)
+
+ newTestOrderForStats(t, username, "approved", productID, 1, 42, 2, time.Now()) // net 40
+
+ c, rec := monthStatsContext("")
+ handlers.GetAdminStatsByMonth(c)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GetAdminStatsByMonth doit réussir: got=%d body=%s", rec.Code, rec.Body.String())
+ }
+
+ var resp struct {
+ Summary struct {
+ TotalRevenue float64 `json:"total_revenue"`
+ } `json:"summary"`
+ ByDay []struct {
+ Revenue float64 `json:"revenue"`
+ } `json:"by_day"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("réponse JSON invalide: %v", err)
+ }
+
+ sum := 0.0
+ for _, d := range resp.ByDay {
+ sum += d.Revenue
+ }
+ if sum != resp.Summary.TotalRevenue {
+ t.Errorf("summary.total_revenue (%.2f) doit correspondre à la somme de by_day[].revenue (%.2f)", resp.Summary.TotalRevenue, sum)
+ }
+}
diff --git a/backend/gestion/workers/cron_auto_assign.go b/backend/gestion/workers/cron_auto_assign.go
index 846afb06..c7afcb56 100644
--- a/backend/gestion/workers/cron_auto_assign.go
+++ b/backend/gestion/workers/cron_auto_assign.go
@@ -83,7 +83,8 @@ func processAutoAssignmentWithPriority(database *db.Database, geoService *servic
}
// Tenter l'assignation
- success := tryAssignCommandWithPriority(database, geoService, commandID, address, priority, int(waitingTime.Minutes()))
+ username, _ := cmd["username"].(string)
+ success := tryAssignCommandWithPriority(database, geoService, commandID, username, address, priority, int(waitingTime.Minutes()))
if success {
assignedCount++
} else {
@@ -103,6 +104,7 @@ func tryAssignCommandWithPriority(
database *db.Database,
geoService *services.GeoService,
commandID int,
+ username string,
address string,
priority int,
waitingMinutes int,
@@ -114,6 +116,7 @@ func tryAssignCommandWithPriority(
location, err := geoService.GeocodeAddress(address)
if err != nil {
log.Printf("❌ [CRON] Cmd %d - Géocodage échoué: %v", commandID, err)
+ notifyGeocodeFailure(database, username, commandID, address)
return false
}
@@ -198,3 +201,26 @@ func tryAssignCommandWithPriority(
return true
}
+
+// notifyGeocodeFailure avertit le client que l'adresse de sa commande n'a pas
+// pu être localisée, pour qu'il puisse la corriger. Le cron retente chaque
+// minute tant que la commande reste pending : un cooldown Redis d'une heure
+// évite de spammer le client à chaque cycle avec la même erreur.
+func notifyGeocodeFailure(database *db.Database, username string, commandID int, address string) {
+ if username == "" {
+ return
+ }
+ cooldownKey := fmt.Sprintf("notif:cooldown:geocode_fail:%d", commandID)
+ set, err := db.Redis.SetNX(db.RedisCtx, cooldownKey, "1", time.Hour).Result()
+ if err != nil || !set {
+ return
+ }
+ clientOrderID := database.GetClientOrderID(commandID)
+ msg := fmt.Sprintf(
+ "Ta commande #%d ne peut pas être assignée : l'adresse \"%s\" n'a pas été trouvée. Merci de vérifier et corriger l'adresse de livraison.",
+ clientOrderID, address,
+ )
+ if err := database.NotifyClient(username, commandID, "address_error", msg); err != nil {
+ log.Printf("⚠️ [CRON] Cmd %d - Erreur notification échec géocodage: %v", commandID, err)
+ }
+}
diff --git a/backend/gestion/workers/redis_worker.go b/backend/gestion/workers/redis_worker.go
index 26ac29e7..ae092073 100644
--- a/backend/gestion/workers/redis_worker.go
+++ b/backend/gestion/workers/redis_worker.go
@@ -17,8 +17,6 @@ func StartRedisWorkers(database *db.Database) {
go AutoAssignWorker(database)
- go StockCleanupWorker(database)
-
go PointsSyncWorker(database)
log.Println("✅ Tous les workers Redis sont démarrés")
@@ -76,48 +74,6 @@ func AutoAssignWorker(database *db.Database) {
}
}
-// ============================================
-// WORKER NETTOYAGE STOCK
-// ============================================
-
-// StockCleanupWorker nettoie les réservations expirées
-func StockCleanupWorker(database *db.Database) {
- ticker := time.NewTicker(5 * time.Minute)
- defer ticker.Stop()
-
- log.Println("🧹 Worker Nettoyage Stock démarré (check toutes les 5 min)")
-
- for range ticker.C {
- cleanedCount := 0
-
- // Récupérer toutes les clés de réservation
- keys, err := db.Redis.Keys(db.RedisCtx, "stock:reserve:*").Result()
- if err != nil {
- log.Printf("⚠️ Erreur récupération réservations: %v", err)
- continue
- }
-
- for _, key := range keys {
- // Vérifier si la réservation est expirée
- ttl, err := db.Redis.TTL(db.RedisCtx, key).Result()
- if err != nil {
- continue
- }
-
- // Si TTL <= 0, la réservation est expirée
- if ttl <= 0 {
- // Redis va automatiquement supprimer la clé
- // Mais on peut log pour traçabilité
- cleanedCount++
- }
- }
-
- if cleanedCount > 0 {
- log.Printf("🧹 %d réservations de stock expirées nettoyées", cleanedCount)
- }
- }
-}
-
// ============================================
// WORKER SYNCHRONISATION POINTS
// ============================================
diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts
index 3584518c..c442501c 100644
--- a/frontend-admin/src/api/api_admin.ts
+++ b/frontend-admin/src/api/api_admin.ts
@@ -1109,6 +1109,7 @@ export interface PointsTier {
export interface RewardCategoryConfig {
category: string;
+ type: "free_product" | "half_price_product";
all_products: boolean;
product_ids: number[];
}
@@ -1121,7 +1122,6 @@ export interface RewardItem {
export interface PointsReward {
threshold: number;
- type: "free_product" | "half_price_product" | "custom";
description: string;
category_configs: RewardCategoryConfig[];
reward_items: RewardItem[];
diff --git a/frontend-admin/src/screens/admin/SettingsScreen.tsx b/frontend-admin/src/screens/admin/SettingsScreen.tsx
index bca1526a..e58d03a3 100644
--- a/frontend-admin/src/screens/admin/SettingsScreen.tsx
+++ b/frontend-admin/src/screens/admin/SettingsScreen.tsx
@@ -686,14 +686,13 @@ function TiersSection({
const REWARD_ACCENT = "#f59e0b";
-const REWARD_TYPES: { value: PointsReward["type"]; label: string; icon: string }[] = [
+const REWARD_TYPES: { value: RewardCategoryConfig["type"]; label: string; icon: string }[] = [
{ value: "free_product", label: "Produit offert", icon: "gift-outline" },
{ value: "half_price_product", label: "Produit à -50%", icon: "pricetag-outline" },
];
const EMPTY_REWARD: PointsReward = {
threshold: 20,
- type: "free_product",
description: "",
category_configs: [],
reward_items: [],
@@ -822,7 +821,7 @@ function CentralRewardSection({
const getCatConfig = (catName: string): RewardCategoryConfig =>
r.category_configs.find((c) => c.category === catName) ??
- { category: catName, all_products: true, product_ids: [] };
+ { category: catName, type: "free_product", all_products: true, product_ids: [] };
const isCatSelected = (catName: string) =>
r.category_configs.some((c) => c.category === catName);
@@ -831,7 +830,7 @@ function CentralRewardSection({
if (isCatSelected(catName)) {
update({ category_configs: r.category_configs.filter((c) => c.category !== catName) });
} else {
- update({ category_configs: [...r.category_configs, { category: catName, all_products: true, product_ids: [] }] });
+ update({ category_configs: [...r.category_configs, { category: catName, type: "free_product", all_products: true, product_ids: [] }] });
}
};
@@ -894,34 +893,6 @@ function CentralRewardSection({
- {/* Type */}
-
- Type de récompense
-
- {REWARD_TYPES.map((rt) => {
- const sel = r.type === rt.value;
- return (
- update({ type: rt.value })}
- style={{
- flexDirection: "row", alignItems: "center", gap: spacing.xs,
- paddingHorizontal: spacing.m, paddingVertical: spacing.s,
- borderRadius: borderRadius.sm, borderWidth: 1.5,
- borderColor: sel ? REWARD_ACCENT : colors.border,
- backgroundColor: sel ? REWARD_ACCENT + "20" : "transparent",
- }}
- >
-
-
- {rt.label}
-
-
- );
- })}
-
-
-
{/* Description */}
Description affichée au client
@@ -935,12 +906,12 @@ function CentralRewardSection({
/>
- {/* Produits récompense — uniquement pour le type "Produit offert" */}
- {r.type === "free_product" && (
+ {/* Produits récompense — proposés au client selon le type choisi
+ pour la catégorie de chaque produit (voir "Catégories éligibles" ci-dessous) */}
Produits ajoutés au panier
- Quand le client réclame sa récompense, ces produits sont automatiquement ajoutés à son panier (gratuits). Il doit commander au moins un produit normal.
+ Quand le client réclame sa récompense, ces produits sont automatiquement ajoutés à son panier — gratuits ou à -50% selon le type configuré pour la catégorie du produit. Il doit commander au moins un produit normal.
{r.reward_items.map((item, idx) => {
@@ -1062,14 +1033,14 @@ function CentralRewardSection({
)}
- )}
- {/* Catégories éligibles — uniquement pour le type "Produit à -50%" */}
- {r.type === "half_price_product" && (
+ {/* Catégories éligibles — chaque catégorie choisit son propre type
+ (produit offert ou -50%), qui s'applique aux produits récompense
+ de cette catégorie configurés ci-dessus */}
- Catégories éligibles à -50%
+ Catégories éligibles
- Sélectionnez les catégories, puis pour chacune choisissez tous les produits ou une sélection.
+ Sélectionnez les catégories, choisissez le type de récompense pour chacune, puis tous les produits ou une sélection.
{allCategories.length === 0 ? (
Aucune catégorie disponible
@@ -1103,15 +1074,41 @@ function CentralRewardSection({
/>
- {/* Sélecteur produits (visible si catégorie sélectionnée) */}
+ {/* Type + sélecteur produits (visible si catégorie sélectionnée) */}
{selected && (
-
+
+
+ {REWARD_TYPES.map((rt) => {
+ const cfg = getCatConfig(cat.name);
+ const sel = (cfg.type || "free_product") === rt.value;
+ return (
+ updateCatConfig({ ...cfg, type: rt.value })}
+ style={{
+ flexDirection: "row", alignItems: "center", gap: 4,
+ paddingHorizontal: spacing.s, paddingVertical: 4,
+ borderRadius: borderRadius.sm, borderWidth: 1.5,
+ borderColor: sel ? REWARD_ACCENT : colors.border,
+ backgroundColor: sel ? REWARD_ACCENT + "22" : "transparent",
+ }}
+ >
+
+
+ {rt.label}
+
+
+ );
+ })}
+
+
+
)}
);
@@ -1119,25 +1116,23 @@ function CentralRewardSection({
)}
- )}
{/* Récapitulatif */}
{(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && (
Récapitulatif
- Dès {r.threshold} pts par type →{" "}
- {REWARD_TYPES.find((x) => x.value === r.type)?.label}
+ Dès {r.threshold} pts par type de points → récompense débloquée
{r.description !== "" && (
"{r.description}"
)}
- {r.type === "half_price_product" && r.category_configs.map((cfg) => (
+ {r.category_configs.map((cfg) => (
- • Éligible à -50% : {cfg.category} — {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`}
+ • {REWARD_TYPES.find((x) => x.value === (cfg.type || "free_product"))?.label} : {cfg.category} — {cfg.all_products ? "tous les produits" : `${cfg.product_ids.length} produit(s)`}
))}
- {r.type === "free_product" && r.reward_items.filter((it) => it.product_id > 0).map((it, idx) => {
+ {r.reward_items.filter((it) => it.product_id > 0).map((it, idx) => {
const prod = Object.values(productsByCategory).flat().find((p) => p.id === it.product_id);
return (
diff --git a/frontend-admin/src/screens/delivery/DashboardScreen.tsx b/frontend-admin/src/screens/delivery/DashboardScreen.tsx
index 734ad1a5..626da74d 100644
--- a/frontend-admin/src/screens/delivery/DashboardScreen.tsx
+++ b/frontend-admin/src/screens/delivery/DashboardScreen.tsx
@@ -715,7 +715,7 @@ export default function DashboardScreen() {
{prod.is_reward && (
- Offert
+ Récompense
)}
@@ -724,7 +724,7 @@ export default function DashboardScreen() {
- {prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
+ {prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
))}
@@ -2018,7 +2018,7 @@ export default function DashboardScreen() {
{prod.is_reward && (
- Offert
+ Récompense
)}
@@ -2027,7 +2027,7 @@ export default function DashboardScreen() {
- {prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
+ {prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}€`}
))}
diff --git a/frontend-prep/src/api/api.ts b/frontend-prep/src/api/api.ts
index 6845f306..6920a774 100644
--- a/frontend-prep/src/api/api.ts
+++ b/frontend-prep/src/api/api.ts
@@ -2168,6 +2168,7 @@ export const unlinkTelegram = async (): Promise => {
export type RewardCategoryConfig = {
category: string;
+ type: "free_product" | "half_price_product";
all_products: boolean;
product_ids: number[];
product_names: string[];
@@ -2179,6 +2180,7 @@ export type RewardItemConfig = {
product_name: string;
quantity: number;
price: number;
+ type: "free_product" | "half_price_product";
};
export type PointsPoolInfo = {
@@ -2194,7 +2196,6 @@ export type PointsPoolInfo = {
export type PointsRewardConfig = {
threshold: number;
- type: string;
description: string;
reward_items: RewardItemConfig[];
};
diff --git a/frontend-prep/src/pages/User/Cart.tsx b/frontend-prep/src/pages/User/Cart.tsx
index 08e3ec56..e8e24815 100644
--- a/frontend-prep/src/pages/User/Cart.tsx
+++ b/frontend-prep/src/pages/User/Cart.tsx
@@ -187,8 +187,10 @@ function Cart() {
{item.quantity}g
- {item.is_reward ? (
+ {item.is_reward && item.price === 0 ? (
Offert
+ ) : item.is_reward ? (
+ {item.price.toFixed(2)} €
) : (
`${item.price.toFixed(2)} €`
)}
diff --git a/frontend-prep/src/pages/User/ConsultationHistorique.tsx b/frontend-prep/src/pages/User/ConsultationHistorique.tsx
index cce6b954..765502bb 100644
--- a/frontend-prep/src/pages/User/ConsultationHistorique.tsx
+++ b/frontend-prep/src/pages/User/ConsultationHistorique.tsx
@@ -747,13 +747,18 @@ function ConsultationHistorique() {
item.quantity !== 1
? `×${item.quantity}`
: ""}
- {item.price > 0
- ? ` · valeur ${item.price.toFixed(2)} €`
+ {item.type ===
+ "half_price_product" &&
+ item.price > 0
+ ? ` · ${item.price.toFixed(2)} €`
: ""}
- Offert
+ {item.type ===
+ "half_price_product"
+ ? "-50%"
+ : "Offert"}
);
diff --git a/frontend-prep/src/pages/User/ProductDetail.css b/frontend-prep/src/pages/User/ProductDetail.css
index 1a08706f..ae11ec30 100644
--- a/frontend-prep/src/pages/User/ProductDetail.css
+++ b/frontend-prep/src/pages/User/ProductDetail.css
@@ -594,4 +594,55 @@
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
+}
+
+/* Modal stock insuffisant */
+.stock-warning-modal {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.75);
+ backdrop-filter: blur(6px);
+ z-index: 9999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1rem;
+}
+
+.stock-warning-modal-content {
+ position: relative;
+ width: 100%;
+ max-width: 340px;
+ padding: 2rem;
+ text-align: center;
+ background: #1a1a1a;
+ border-radius: 12px;
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
+ color: #fff;
+}
+
+.stock-warning-close-btn {
+ position: absolute;
+ top: 0.75rem;
+ right: 0.75rem;
+ background: transparent;
+ border: none;
+ color: #aaa;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.stock-warning-ok-btn {
+ background: #ef4444;
+ color: #fff;
+ border: none;
+ border-radius: 8px;
+ padding: 0.6rem 1.5rem;
+ font-weight: 700;
+ cursor: pointer;
}
\ No newline at end of file
diff --git a/frontend-prep/src/pages/User/ProductDetail.tsx b/frontend-prep/src/pages/User/ProductDetail.tsx
index 00396572..b695232a 100644
--- a/frontend-prep/src/pages/User/ProductDetail.tsx
+++ b/frontend-prep/src/pages/User/ProductDetail.tsx
@@ -1,5 +1,7 @@
import { useParams, useNavigate } from "react-router-dom";
import { useState, useEffect } from "react";
+import { createPortal } from "react-dom";
+import { X } from "lucide-react";
import {
getProductById,
getCategories,
@@ -25,6 +27,7 @@ function ProductDetail() {
// floats
const [selectedGrams, setSelectedGrams] = useState(null);
const [selectedPrice, setSelectedPrice] = useState(0.0);
+ const [stockWarning, setStockWarning] = useState<{ wanted: number; available: number } | null>(null);
// ✅ TOAST STATE
const [toast, setToast] = useState<{
@@ -171,6 +174,11 @@ function ProductDetail() {
return;
}
+ if (product.stock > 0 && selectedGrams > product.stock) {
+ setStockWarning({ wanted: selectedGrams, available: product.stock });
+ return;
+ }
+
addToCart({
product_id: product.id,
name_product: product.name,
@@ -363,6 +371,50 @@ function ProductDetail() {
+
+ {/* Modal stock insuffisant */}
+ {stockWarning &&
+ createPortal(
+ setStockWarning(null)}
+ >
+
e.stopPropagation()}
+ >
+
+
+ ⚠️
+
+
+ Stock insuffisant
+
+
+ Vous avez sélectionné{" "}
+ {stockWarning.wanted}{product.unit || "g"} mais il ne
+ reste que{" "}
+
+ {stockWarning.available}{product.unit || "g"}
+ {" "}
+ disponible pour {product.name}.
+
+
+
+
,
+ document.body,
+ )}
>
);
}
diff --git a/mobile/src/api/api.ts b/mobile/src/api/api.ts
index 20977ee1..495487bf 100644
--- a/mobile/src/api/api.ts
+++ b/mobile/src/api/api.ts
@@ -964,6 +964,7 @@ export const toggle2FA = async (
export type RewardCategoryConfig = {
category: string;
+ type: "free_product" | "half_price_product";
all_products: boolean;
product_ids: number[];
product_names: string[];
@@ -975,6 +976,7 @@ export type RewardItemConfig = {
product_name: string;
quantity: number;
price: number;
+ type: "free_product" | "half_price_product";
};
export type PointsPoolInfo = {
@@ -990,7 +992,6 @@ export type PointsPoolInfo = {
export type PointsRewardConfig = {
threshold: number;
- type: string;
description: string;
reward_items: RewardItemConfig[];
};
diff --git a/mobile/src/screens/client/OrderHistoryScreen.tsx b/mobile/src/screens/client/OrderHistoryScreen.tsx
index a163ccf0..61564df6 100644
--- a/mobile/src/screens/client/OrderHistoryScreen.tsx
+++ b/mobile/src/screens/client/OrderHistoryScreen.tsx
@@ -1132,11 +1132,18 @@ export default function OrderHistoryScreen() {
)}
- {item.price > 0 && (
+ {item.type ===
+ "half_price_product" ? (
- {item.price}€
+ -50% · {item.price}€
+
+ ) : (
+
+ Offert
)}