chore: fix prices

This commit is contained in:
2026-03-02 03:28:45 +01:00
parent e7a6dc2230
commit 6c8af9acd4
10 changed files with 266 additions and 41 deletions
+91 -11
View File
@@ -10,9 +10,9 @@ import (
// AddProductInBasket ajoute un produit au panier de l'utilisateur
func (d *Database) AddProductInBasket(username, nameProduct string, quantity float64, category string) (*models.Panier, error) {
// Rechercher le produit par son nom et catégorie
// Rechercher le produit par son nom et catégorie (case-insensitive)
var productID int
productQuery := `SELECT id FROM products WHERE name = $1 AND category = $2`
productQuery := `SELECT id FROM products WHERE LOWER(name) = LOWER($1) AND LOWER(category) = LOWER($2)`
err := d.QueryRow(productQuery, nameProduct, category).Scan(&productID)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("produit '%s' non trouvé dans la catégorie '%s'", nameProduct, category)
@@ -29,18 +29,19 @@ func (d *Database) AddProductInBasket(username, nameProduct string, quantity flo
// Vérifier si le produit existe déjà dans le panier
var existingID int
var existingQuantity float64
checkQuery := `SELECT id, quantity FROM baskets WHERE username = $1 AND product_id = $2`
err = d.QueryRow(checkQuery, username, productID).Scan(&existingID, &existingQuantity)
var existingQuantity, existingPrice float64
checkQuery := `SELECT id, quantity, price FROM baskets WHERE username = $1 AND product_id = $2`
err = d.QueryRow(checkQuery, username, productID).Scan(&existingID, &existingQuantity, &existingPrice)
if err == nil {
// Produit déjà dans le panier : mettre à jour quantité et prix
// Produit déjà dans le panier : cumuler quantité et prix total de la ligne
newQuantity := existingQuantity + quantity
newPrice := existingPrice + price
updateQuery := `UPDATE baskets SET quantity = $1, price = $2, created_at = CURRENT_TIMESTAMP
WHERE id = $3 RETURNING id, username, product_id, quantity, price, created_at`
var basket models.Panier
err = d.QueryRow(updateQuery, newQuantity, price, existingID).Scan(
err = d.QueryRow(updateQuery, newQuantity, newPrice, existingID).Scan(
&basket.ID,
&basket.Username,
&basket.ProductID,
@@ -75,7 +76,85 @@ func (d *Database) AddProductInBasket(username, nameProduct string, quantity flo
return &basket, nil
}
// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée
// GetProductPriceByID récupère le prix d'un produit par son ID et quantité (NUMERIC exact)
func (d *Database) GetProductPriceByID(productID int, quantity float64) (float64, error) {
var price float64
// Comparaison NUMERIC précise : évite les problèmes float64 vs NUMERIC(10,3)
exactQuery := `
SELECT price FROM product_prices
WHERE product_id = $1 AND quantity = ROUND($2::NUMERIC, 3)
LIMIT 1
`
err := d.QueryRow(exactQuery, productID, quantity).Scan(&price)
if err == nil {
return price, nil
}
// Fallback : palier inférieur le plus proche
tierQuery := `
SELECT price FROM product_prices
WHERE product_id = $1 AND quantity <= ROUND($2::NUMERIC, 3)
ORDER BY quantity DESC LIMIT 1
`
err = d.QueryRow(tierQuery, productID, quantity).Scan(&price)
if err != nil {
return 0, fmt.Errorf("aucun prix trouvé pour product_id=%d qty=%.3f: %w", productID, quantity, err)
}
return price, nil
}
// GetProductStockByID récupère le stock d'un produit par son ID
func (d *Database) GetProductStockByID(productID int) (float64, error) {
var stock float64
err := d.QueryRow(`SELECT stock FROM products WHERE id = $1`, productID).Scan(&stock)
if err != nil {
return 0, fmt.Errorf("produit %d non trouvé: %w", productID, err)
}
return stock, nil
}
// AddProductInBasketByID ajoute un produit au panier en utilisant son ID directement
func (d *Database) AddProductInBasketByID(username string, productID int, quantity float64) (*models.Panier, error) {
price, err := d.GetProductPriceByID(productID, quantity)
if err != nil {
return nil, fmt.Errorf("erreur récupération prix: %w", err)
}
var existingID int
var existingQuantity, existingPrice float64
checkQuery := `SELECT id, quantity, price FROM baskets WHERE username = $1 AND product_id = $2`
err = d.QueryRow(checkQuery, username, productID).Scan(&existingID, &existingQuantity, &existingPrice)
if err == nil {
newQuantity := existingQuantity + quantity
newPrice := existingPrice + price
updateQuery := `UPDATE baskets SET quantity = $1, price = $2, created_at = CURRENT_TIMESTAMP
WHERE id = $3 RETURNING id, username, product_id, quantity, price, created_at`
var basket models.Panier
err = d.QueryRow(updateQuery, newQuantity, newPrice, existingID).Scan(
&basket.ID, &basket.Username, &basket.ProductID, &basket.Quantity, &basket.Price, &basket.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur lors de la mise à jour du panier: %w", err)
}
return &basket, nil
}
insertQuery := `INSERT INTO baskets (username, product_id, quantity, price, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, created_at`
var basket models.Panier
err = d.QueryRow(insertQuery, username, productID, quantity, price).Scan(
&basket.ID, &basket.Username, &basket.ProductID, &basket.Quantity, &basket.Price, &basket.CreatedAt,
)
if err != nil {
return nil, fmt.Errorf("erreur lors de l'ajout au panier: %w", err)
}
return &basket, nil
}
// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée (legacy)
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
var price float64
@@ -100,7 +179,7 @@ func (d *Database) GetProductPrice(name, category string, quantity float64) (flo
func (d *Database) GetProductStock(name, category string) (float64, error) {
var stock float64
query := `SELECT stock FROM products WHERE name = $1 AND category = $2`
query := `SELECT stock FROM products WHERE LOWER(name) = LOWER($1) AND LOWER(category) = LOWER($2)`
err := d.QueryRow(query, name, category).Scan(&stock)
if err != nil {
return 0, fmt.Errorf("produit non trouvé: %w", err)
@@ -109,7 +188,7 @@ func (d *Database) GetProductStock(name, category string) (float64, error) {
}
func (d *Database) DecrementProductStock(name, category string, quantity float64) error {
query := `UPDATE products SET stock = stock - $1 WHERE name = $2 AND category = $3 AND stock >= $1`
query := `UPDATE products SET stock = stock - $1 WHERE LOWER(name) = LOWER($2) AND LOWER(category) = LOWER($3) AND stock >= $1`
result, err := d.Exec(query, quantity, name, category)
if err != nil {
return fmt.Errorf("erreur mise à jour stock: %w", err)
@@ -223,8 +302,9 @@ func (d *Database) ClearBasket(username string) error {
}
// GetBasketTotal calcule le montant total du panier d'un utilisateur
// price dans baskets = prix total de la ligne (cumul des ajouts)
func (d *Database) GetBasketTotal(username string) (float64, error) {
query := `SELECT COALESCE(SUM(quantity * price), 0) as total
query := `SELECT COALESCE(SUM(price), 0) as total
FROM baskets
WHERE username = $1`
+19
View File
@@ -990,3 +990,22 @@ func (d *Database) CanUserAccessCommand(
return exists, err
}
// SaveClientPushToken enregistre le push token Expo d'un client
func (d *Database) SaveClientPushToken(clientID int, pushToken string) error {
_, err := d.Exec(`UPDATE clients SET push_token = $1 WHERE id = $2`, pushToken, clientID)
return err
}
// DeleteClientPushToken supprime le push token d'un client
func (d *Database) DeleteClientPushToken(clientID int) error {
_, err := d.Exec(`UPDATE clients SET push_token = NULL WHERE id = $1`, clientID)
return err
}
// GetClientPushToken retourne le push token d'un client par son username
func (d *Database) GetClientPushToken(username string) (string, error) {
var token string
err := d.QueryRow(`SELECT COALESCE(push_token, '') FROM clients WHERE username = $1`, username).Scan(&token)
return token, err
}
+7 -2
View File
@@ -71,11 +71,16 @@ func InitDB() *Database {
log.Println("✅ Tables créées avec succès")
// Migration: ajouter colonne must_change_password si elle n'existe pas
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT TRUE`); err != nil {
// Migration: ajouter colonne must_change_password si elle n'existe pas (DEFAULT FALSE pour les clients existants)
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration must_change_password: %v", err)
}
// Migration: ajouter colonne push_token pour les notifications push
if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS push_token TEXT`); err != nil {
log.Fatalf("❌ Erreur migration push_token: %v", err)
}
// Lancer le nettoyage périodique des tokens expirés
go database.cleanExpiredTokensPeriodically()
+48 -1
View File
@@ -1,9 +1,11 @@
package db
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
@@ -21,12 +23,57 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
notifJSON, _ := json.Marshal(notification)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour) // Expire après 7 jours
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
// Envoyer push notification si le client a un token enregistré
pushToken, err := d.GetClientPushToken(username)
if err == nil && pushToken != "" {
go sendExpoPush(pushToken, "Uber Stup", message, commandID, notifType)
}
log.Printf("📬 Notification envoyée à %s: %s", username, message)
return nil
}
func sendExpoPush(token, title, body string, commandID int, notifType string) {
payload := map[string]interface{}{
"to": token,
"title": title,
"body": body,
"channelId": "orders",
"data": map[string]interface{}{
"command_id": commandID,
"type": notifType,
},
"sound": "default",
}
jsonBody, err := json.Marshal(payload)
if err != nil {
log.Printf("❌ [EXPO_PUSH] Erreur marshal: %v", err)
return
}
req, err := http.NewRequest("POST", "https://exp.host/--/api/v2/push/send", bytes.NewBuffer(jsonBody))
if err != nil {
log.Printf("❌ [EXPO_PUSH] Erreur création requête: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Accept-Encoding", "gzip, deflate")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Printf("❌ [EXPO_PUSH] Erreur envoi: %v", err)
return
}
defer resp.Body.Close()
log.Printf("✅ [EXPO_PUSH] Push envoyé à %s (status: %d)", token, resp.StatusCode)
}
// AddDeliveryRating ajoute une note pour un livreur
func (d *Database) AddDeliveryRating(livreurUsername string, commandID, rating int, comment string) error {
query := `
+48
View File
@@ -9,6 +9,54 @@ import (
"github.com/gin-gonic/gin"
)
// RegisterPushToken enregistre le push token Expo d'un client
// POST /api/v1/push-token
func RegisterPushToken(c *gin.Context) {
clientID := c.GetInt("client_id")
if clientID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
var req struct {
PushToken string `json:"push_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "push_token requis"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.SaveClientPushToken(clientID, req.PushToken); err != nil {
log.Printf("❌ [PUSH_TOKEN] Erreur sauvegarde token pour client %d: %v", clientID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"})
return
}
log.Printf("✅ [PUSH_TOKEN] Token enregistré pour client %d", clientID)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UnregisterPushToken supprime le push token d'un client (au logout)
// DELETE /api/v1/push-token
func UnregisterPushToken(c *gin.Context) {
clientID := c.GetInt("client_id")
if clientID == 0 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.DeleteClientPushToken(clientID); err != nil {
log.Printf("❌ [PUSH_TOKEN] Erreur suppression token pour client %d: %v", clientID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"})
return
}
log.Printf("✅ [PUSH_TOKEN] Token supprimé pour client %d", clientID)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// GetClientNotifications retourne les notifications du client connecté
// GET /api/v1/notifications
func GetClientNotifications(c *gin.Context) {
+45 -26
View File
@@ -16,6 +16,7 @@ import (
type BasketsRequest struct {
Username string `json:"username"`
ProductID int `json:"product_id"`
NameProduct string `json:"name_product"`
Category string `json:"category"`
Quantity float64 `json:"quantity"`
@@ -26,16 +27,14 @@ type BasketsRequest struct {
// ============================================
// POST /api/v1/panier/add
func AddProductsBasket(c *gin.Context) {
db := c.MustGet("database").(*db.Database)
database := c.MustGet("database").(*db.Database)
// Liaison JSON
var req BasketsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Requête invalide", "details": err.Error()})
return
}
// Récupérer le username depuis JWT ou contexte
username, ok := c.Get("username")
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
@@ -43,45 +42,65 @@ func AddProductsBasket(c *gin.Context) {
}
req.Username = username.(string)
// Validation des champs
if req.NameProduct == "" || req.Category == "" || req.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Champs invalides"})
if req.Quantity <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Quantité invalide"})
return
}
// Vérifier le stock
stock, err := db.GetProductStock(req.NameProduct, req.Category)
// Si product_id fourni par le mobile, on l'utilise directement (plus fiable)
if req.ProductID > 0 {
stock, err := database.GetProductStockByID(req.ProductID)
if err != nil {
log.Printf("❌ [ADD_PANIER] Produit %d non trouvé: %v", req.ProductID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
if stock < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Stock insuffisant",
"available": stock,
})
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock})
return
}
// Ajouter au panier (ou mettre à jour si déjà présent)
panier, err := db.AddProductInBasket(req.Username, req.NameProduct, req.Quantity, req.Category)
panier, err := database.AddProductInBasketByID(req.Username, req.ProductID, req.Quantity)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier"})
log.Printf("❌ [ADD_PANIER] Erreur ajout product_id=%d qty=%.3f: %v", req.ProductID, req.Quantity, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier", "details": err.Error()})
return
}
// Décrémenter le stock
if err := db.DecrementProductStock(req.NameProduct, req.Category, req.Quantity); err != nil {
if err := database.DecrementProductStockByID(req.ProductID, req.Quantity); err != nil {
log.Printf("❌ [ADD_PANIER] Erreur décrement stock product_id=%d: %v", req.ProductID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de réserver le stock"})
return
}
c.JSON(http.StatusCreated, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier})
return
}
c.JSON(http.StatusCreated, gin.H{
"success": true,
"message": "Produit ajouté au panier avec succès",
"panier": panier,
})
// Fallback : recherche par nom+catégorie (compatibilité)
if req.NameProduct == "" || req.Category == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "product_id ou name_product+category requis"})
return
}
stock, err := database.GetProductStock(req.NameProduct, req.Category)
if err != nil {
log.Printf("❌ [ADD_PANIER] Produit '%s'/'%s' non trouvé: %v", req.NameProduct, req.Category, err)
c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"})
return
}
if stock < req.Quantity {
c.JSON(http.StatusBadRequest, gin.H{"error": "Stock insuffisant", "available": stock})
return
}
panier, err := database.AddProductInBasket(req.Username, req.NameProduct, req.Quantity, req.Category)
if err != nil {
log.Printf("❌ [ADD_PANIER] Erreur ajout '%s': %v", req.NameProduct, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible d'ajouter le produit au panier", "details": err.Error()})
return
}
if err := database.DecrementProductStock(req.NameProduct, req.Category, req.Quantity); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Impossible de réserver le stock"})
return
}
c.JSON(http.StatusCreated, gin.H{"success": true, "message": "Produit ajouté au panier avec succès", "panier": panier})
}
// ============================================
@@ -144,7 +163,7 @@ func GetAllBaskets(c *gin.Context) {
var totalAmount float64
for _, item := range baskets {
totalAmount += item.Price * item.Quantity
totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts)
}
log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount)
+1
View File
@@ -17,5 +17,6 @@ type Client struct {
CancellationsCount int `json:"cancellations_count"` // ✅ NOUVEAU
LastPenaltyReason string `json:"last_penalty_reason"`
MustChangePassword bool `json:"must_change_password"`
PushToken string `json:"-"`
CreatedAt time.Time `json:"created_at"`
}
+4
View File
@@ -95,6 +95,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cartGroupV1.GET("/notifications", handlers.GetClientNotifications)
cartGroupV1.POST("/notifications/read", handlers.MarkNotificationsRead)
// 📱 PUSH TOKEN
cartGroupV1.POST("/push-token", handlers.RegisterPushToken)
cartGroupV1.DELETE("/push-token", handlers.UnregisterPushToken)
// 👤 PROFIL CLIENT - MODIFICATION PAR LE CLIENT
cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil
}
+1
View File
@@ -169,6 +169,7 @@ export const getCart = async (username: string) => {
export const addToCart = async (cartItem: {
username?: string;
product_id?: number;
name_product: string;
category: string;
quantity: number;
+1
View File
@@ -122,6 +122,7 @@ export function CartProvider({ children }: { children: ReactNode }) {
try {
const response = await apiAddToCart({
username,
product_id: item.product_id,
name_product: cleanName,
category: (item.category || "autre").toLowerCase().trim(),
quantity: Number(item.quantity),