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 )}