chore: build
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user