chore: build
Backend - Build & Lint / build (push) Failing after 28m7s
Frontend Admin - EAS Build / build (push) Failing after 1h39m13s
Frontend Client - EAS Build / build (push) Failing after 1h38m12s
Frontend Web - Build & Lint / build (push) Failing after 10m56s

This commit is contained in:
Xor290
2026-08-19 17:49:04 +02:00
parent 1623a9eafd
commit 901d013830
33 changed files with 1459 additions and 263 deletions
+24 -3
View File
@@ -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)
+46 -4
View File
@@ -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)
}
+22 -7
View File
@@ -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
}
+35 -22
View File
@@ -37,11 +37,14 @@ func (d *Database) ReadResetAt(key string) time.Time {
// ── Construction des clauses WHERE (filtrage par reset) ────────────────────
// statusFilterClause construit "<baseStatus> [AND created_at >= ?]" et renvoie
// la clause ainsi que les arguments à binder, dans l'ordre.
func statusFilterClause(baseStatus string, resetAt time.Time) (string, []interface{}) {
// statusFilterClause construit "<baseStatus> [AND <dateColumn> >= ?]" 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
+165
View File
@@ -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),
})
}
+115 -1
View File
@@ -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,
-9
View File
@@ -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")
+101 -17
View File
@@ -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
+2 -2
View File
@@ -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
}
+52
View File
@@ -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
}
}
+40 -13
View File
@@ -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 {
-8
View File
@@ -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"`
}
+9 -6
View File
@@ -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
+1
View File
@@ -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)
@@ -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)
}
}
+19 -36
View File
@@ -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)
}
}
@@ -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)
@@ -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)
+55 -10
View File
@@ -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)
+8 -3
View File
@@ -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)
@@ -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)
}
}
+27 -1
View File
@@ -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)
}
}
-44
View File
@@ -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
// ============================================
+1 -1
View File
@@ -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[];
@@ -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({
</View>
</View>
{/* Type */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.s }]}>Type de récompense</Text>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.s }}>
{REWARD_TYPES.map((rt) => {
const sel = r.type === rt.value;
return (
<TouchableOpacity
key={rt.value}
onPress={() => 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",
}}
>
<Ionicons name={rt.icon as any} size={14} color={sel ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 13, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{rt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* Description */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Description affichée au client</Text>
@@ -935,12 +906,12 @@ function CentralRewardSection({
/>
</View>
{/* 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) */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Produits ajoutés au panier</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
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.
</Text>
<View style={{ gap: spacing.s }}>
{r.reward_items.map((item, idx) => {
@@ -1062,14 +1033,14 @@ function CentralRewardSection({
)}
</View>
</View>
)}
{/* 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 */}
<View>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles à -50%</Text>
<Text style={[s.rowLabel, { marginBottom: spacing.xs }]}>Catégories éligibles</Text>
<Text style={[s.rowDesc, { marginBottom: spacing.m }]}>
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.
</Text>
{allCategories.length === 0 ? (
<Text style={[s.hint, { paddingHorizontal: 0 }]}>Aucune catégorie disponible</Text>
@@ -1103,15 +1074,41 @@ function CentralRewardSection({
/>
</TouchableOpacity>
{/* Sélecteur produits (visible si catégorie sélectionnée) */}
{/* Type + sélecteur produits (visible si catégorie sélectionnée) */}
{selected && (
<CategoryProductPicker
catConfig={getCatConfig(cat.name)}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
<View style={{ marginTop: spacing.s, paddingLeft: spacing.m, gap: spacing.s }}>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: spacing.xs }}>
{REWARD_TYPES.map((rt) => {
const cfg = getCatConfig(cat.name);
const sel = (cfg.type || "free_product") === rt.value;
return (
<TouchableOpacity
key={rt.value}
onPress={() => 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",
}}
>
<Ionicons name={rt.icon as any} size={12} color={sel ? REWARD_ACCENT : colors.textMuted} />
<Text style={{ fontSize: 12, fontWeight: sel ? "700" : "400", color: sel ? REWARD_ACCENT : colors.textMuted }}>
{rt.label}
</Text>
</TouchableOpacity>
);
})}
</View>
<CategoryProductPicker
catConfig={getCatConfig(cat.name)}
products={productsByCategory[cat.name] ?? []}
onChange={updateCatConfig}
colors={colors}
s={s}
/>
</View>
)}
</View>
);
@@ -1119,25 +1116,23 @@ function CentralRewardSection({
</View>
)}
</View>
)}
{/* Récapitulatif */}
{(r.category_configs.length > 0 || r.reward_items.filter((it) => it.product_id > 0).length > 0) && (
<View style={{ backgroundColor: REWARD_ACCENT + "12", borderRadius: borderRadius.sm, borderLeftWidth: 3, borderLeftColor: REWARD_ACCENT, padding: spacing.m, gap: 4 }}>
<Text style={{ fontSize: 13, fontWeight: "700", color: REWARD_ACCENT }}>Récapitulatif</Text>
<Text style={{ fontSize: 13, color: colors.textPrimary }}>
Dès <Text style={{ fontWeight: "700" }}>{r.threshold} pts</Text> par type {" "}
{REWARD_TYPES.find((x) => x.value === r.type)?.label}
Dès <Text style={{ fontWeight: "700" }}>{r.threshold} pts</Text> par type de points récompense débloquée
</Text>
{r.description !== "" && (
<Text style={{ fontSize: 12, color: colors.textMuted, fontStyle: "italic" }}>"{r.description}"</Text>
)}
{r.type === "half_price_product" && r.category_configs.map((cfg) => (
{r.category_configs.map((cfg) => (
<Text key={cfg.category} style={{ fontSize: 12, color: colors.textSecondary }}>
É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)`}
</Text>
))}
{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 (
<View key={idx} style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
@@ -715,7 +715,7 @@ export default function DashboardScreen() {
{prod.is_reward && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
<Ionicons name="gift-outline" size={10} color="#f59e0b" />
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
<Text style={{ fontSize: 10, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
</View>
)}
</View>
@@ -724,7 +724,7 @@ export default function DashboardScreen() {
</Text>
</View>
<Text style={[styles.itemPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
</View>
))}
@@ -2018,7 +2018,7 @@ export default function DashboardScreen() {
{prod.is_reward && (
<View style={{ flexDirection: "row", alignItems: "center", gap: 2, backgroundColor: "rgba(245,158,11,0.15)", borderRadius: 4, paddingHorizontal: 4, paddingVertical: 1 }}>
<Ionicons name="gift-outline" size={11} color="#f59e0b" />
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Offert</Text>
<Text style={{ fontSize: 11, color: "#f59e0b", fontWeight: "700" }}>Récompense</Text>
</View>
)}
</View>
@@ -2027,7 +2027,7 @@ export default function DashboardScreen() {
</Text>
</View>
<Text style={[styles.detailProductPrice, prod.is_reward ? { color: "#10b981" } : {}]}>
{prod.is_reward ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
{prod.is_reward && (prod.prix ?? 0) === 0 ? "Offert" : `${(prod.prix ?? 0).toFixed(2)}`}
</Text>
</View>
))}
+2 -1
View File
@@ -2168,6 +2168,7 @@ export const unlinkTelegram = async (): Promise<void> => {
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[];
};
+3 -1
View File
@@ -187,8 +187,10 @@ function Cart() {
</p>
<p className="cart-row-qty">{item.quantity}g</p>
<p className="cart-row-price">
{item.is_reward ? (
{item.is_reward && item.price === 0 ? (
<span style={{ color: "#10b981", fontWeight: 700 }}>Offert</span>
) : item.is_reward ? (
<span style={{ color: "#10b981", fontWeight: 700 }}>{item.price.toFixed(2)} </span>
) : (
`${item.price.toFixed(2)}`
)}
@@ -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)}`
: ""}
</span>
</div>
<span className="reward-product-free">
Offert
{item.type ===
"half_price_product"
? "-50%"
: "Offert"}
</span>
</button>
);
@@ -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;
}
@@ -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<number | null>(null);
const [selectedPrice, setSelectedPrice] = useState<number>(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() {
</div>
</div>
</div>
{/* Modal stock insuffisant */}
{stockWarning &&
createPortal(
<div
className="stock-warning-modal"
onClick={() => setStockWarning(null)}
>
<div
className="stock-warning-modal-content"
onClick={(e) => e.stopPropagation()}
>
<button
className="stock-warning-close-btn"
onClick={() => setStockWarning(null)}
aria-label="Fermer"
>
<X size={18} />
</button>
<div style={{ fontSize: "2.5rem", marginBottom: "0.75rem" }}>
</div>
<p style={{ fontWeight: 700, fontSize: "1.1rem", marginBottom: "0.5rem" }}>
Stock insuffisant
</p>
<p style={{ color: "#aaa", fontSize: "0.95rem", marginBottom: "1.25rem" }}>
Vous avez sélectionné{" "}
<strong>{stockWarning.wanted}{product.unit || "g"}</strong> mais il ne
reste que{" "}
<strong style={{ color: "#ef4444" }}>
{stockWarning.available}{product.unit || "g"}
</strong>{" "}
disponible pour <em>{product.name}</em>.
</p>
<button
className="stock-warning-ok-btn"
onClick={() => setStockWarning(null)}
>
OK
</button>
</div>
</div>,
document.body,
)}
</>
);
}
+2 -1
View File
@@ -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[];
};
@@ -1132,11 +1132,18 @@ export default function OrderHistoryScreen() {
</Text>
)}
</View>
{item.price > 0 && (
{item.type ===
"half_price_product" ? (
<Text
style={styles.pickerItemPrice}
>
{item.price}
-50% · {item.price}
</Text>
) : (
<Text
style={styles.pickerItemPrice}
>
Offert
</Text>
)}
<Ionicons