chore: update
This commit is contained in:
@@ -432,8 +432,8 @@ func (d *Database) GetBasketItems(username string) ([]map[string]interface{}, er
|
||||
|
||||
var items []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var productID, quantity int
|
||||
var price float64
|
||||
var productID int
|
||||
var quantity, price float64
|
||||
if err := rows.Scan(&productID, &quantity, &price); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -876,7 +876,7 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int,
|
||||
|
||||
type ItemPoints struct {
|
||||
Category string
|
||||
Quantite int
|
||||
Quantite float64
|
||||
Prix float64
|
||||
}
|
||||
|
||||
@@ -894,8 +894,11 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int,
|
||||
items = append(items, item)
|
||||
|
||||
// Cumuler par catégorie
|
||||
if item.Category == "zipette" {
|
||||
categoryLower := strings.ToLower(item.Category)
|
||||
if categoryLower == "zipette&co" || categoryLower == "zipette_co" {
|
||||
totalPrixZipette += item.Prix
|
||||
} else if categoryLower == "gros&semi" || categoryLower == "gros_semi" {
|
||||
// gros&semi → 0 points, on ne cumule pas
|
||||
} else {
|
||||
// weed_hash ou autres catégories
|
||||
totalPrixWeedHash += item.Prix
|
||||
@@ -915,10 +918,31 @@ func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int,
|
||||
log.Printf("📊 [CalcPointsTx] %d items - weed_hash: %.2f€, zipette: %.2f€",
|
||||
len(items), totalPrixWeedHash, totalPrixZipette)
|
||||
|
||||
// ✅ ÉTAPE 2: Calculer les points par catégorie
|
||||
// Règle: 1 point par tranche de 10€
|
||||
pointsWeedHash := int(totalPrixWeedHash / 10.0)
|
||||
pointsZipette := int(totalPrixZipette / 10.0)
|
||||
// ✅ ÉTAPE 2: Calculer les points par catégorie avec le bon barème
|
||||
pointsWeedHash := 0
|
||||
switch {
|
||||
case totalPrixWeedHash >= 30 && totalPrixWeedHash <= 50:
|
||||
pointsWeedHash = 1
|
||||
case totalPrixWeedHash >= 60 && totalPrixWeedHash <= 150:
|
||||
pointsWeedHash = 2
|
||||
case totalPrixWeedHash >= 160 && totalPrixWeedHash <= 300:
|
||||
pointsWeedHash = 3
|
||||
case totalPrixWeedHash >= 310 && totalPrixWeedHash <= 400:
|
||||
pointsWeedHash = 5
|
||||
case totalPrixWeedHash >= 400:
|
||||
pointsWeedHash = 10
|
||||
}
|
||||
|
||||
pointsZipette := 0
|
||||
switch {
|
||||
case totalPrixZipette >= 30 && totalPrixZipette <= 100:
|
||||
pointsZipette = 1
|
||||
case totalPrixZipette >= 110 && totalPrixZipette <= 200:
|
||||
pointsZipette = 2
|
||||
case totalPrixZipette >= 210:
|
||||
pointsZipette = 3
|
||||
}
|
||||
|
||||
totalPoints := pointsWeedHash + pointsZipette
|
||||
|
||||
log.Printf("💰 [CalcPointsTx] Points calculés - weed_hash: %d, zipette: %d, total: %d",
|
||||
|
||||
@@ -31,7 +31,7 @@ func validateItemID(itemID int) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateQuantite(quantite int) error {
|
||||
func validateQuantite(quantite float64) error {
|
||||
if quantite <= 0 {
|
||||
return fmt.Errorf("quantité doit être > 0")
|
||||
}
|
||||
@@ -96,7 +96,8 @@ func validateItemStatus(status string) error {
|
||||
func (d *Database) InsertCommandItemWithClientInfo(
|
||||
commandID int,
|
||||
produit string,
|
||||
productID, quantite int,
|
||||
productID int,
|
||||
quantite float64,
|
||||
prix float64,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
|
||||
) error {
|
||||
@@ -231,7 +232,8 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
|
||||
var items []map[string]interface{}
|
||||
|
||||
for rows.Next() {
|
||||
var id, commandID, quantite int
|
||||
var id, commandID int
|
||||
var quantite float64
|
||||
var productID sql.NullInt64 // ✅ FIX: Utiliser NullInt64 pour gérer NULL
|
||||
var produit, clientUsername, clientNom, clientPrenom, clientTelephone string
|
||||
var deliveryAddress, status sql.NullString
|
||||
@@ -345,7 +347,8 @@ func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]inte
|
||||
var items []map[string]interface{}
|
||||
|
||||
for rows.Next() {
|
||||
var id, commandID, quantite int
|
||||
var id, commandID int
|
||||
var quantite float64
|
||||
var productID sql.NullInt64 // ✅ FIX: NullInt64
|
||||
var produit, clientUsername, clientNom, clientPrenom, clientTelephone string
|
||||
var deliveryAddress, status sql.NullString
|
||||
|
||||
@@ -82,7 +82,7 @@ func (d *Database) CreateCommand(username string) (*models.Command, error) {
|
||||
|
||||
type BasketItem struct {
|
||||
ProductID int
|
||||
Quantity int
|
||||
Quantity float64
|
||||
Price float64
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*
|
||||
|
||||
type BasketItem struct {
|
||||
ProductID int
|
||||
Quantity int
|
||||
Quantity float64
|
||||
Price float64
|
||||
}
|
||||
|
||||
|
||||
@@ -76,11 +76,55 @@ func InitDB() *Database {
|
||||
log.Fatalf("❌ Erreur migration must_change_password: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne push_token pour les notifications push
|
||||
// Migration: ajouter colonne push_token pour les notifications push (clients)
|
||||
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)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne push_token pour les notifications push (livreurs/users)
|
||||
if _, err = database.Exec(`ALTER TABLE users ADD COLUMN IF NOT EXISTS push_token TEXT`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration push_token users: %v", err)
|
||||
}
|
||||
|
||||
// Migration: ajouter colonne unit pour l'unité de mesure des produits (kg, g, bag, l, cl, pcs, u)
|
||||
if _, err = database.Exec(`ALTER TABLE products ADD COLUMN IF NOT EXISTS unit VARCHAR(10) NOT NULL DEFAULT 'u'`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration unit products: %v", err)
|
||||
}
|
||||
|
||||
// Migration: baskets.quantity INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires (ex: 0.5g)
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'baskets' AND column_name = 'quantity'
|
||||
AND data_type = 'integer'
|
||||
) THEN
|
||||
ALTER TABLE baskets ALTER COLUMN quantity TYPE NUMERIC(10,3) USING quantity::NUMERIC(10,3);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
|
||||
}
|
||||
|
||||
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
|
||||
if _, err = database.Exec(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'command_items' AND column_name = 'quantite'
|
||||
AND data_type = 'integer'
|
||||
) THEN
|
||||
ALTER TABLE command_items ALTER COLUMN quantite TYPE NUMERIC(10,3) USING quantite::NUMERIC(10,3);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
`); err != nil {
|
||||
log.Fatalf("❌ Erreur migration command_items.quantite: %v", err)
|
||||
}
|
||||
|
||||
// Lancer le nettoyage périodique des tokens expirés
|
||||
go database.cleanExpiredTokensPeriodically()
|
||||
|
||||
@@ -151,6 +195,7 @@ func (db *Database) createTables() error {
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
stock DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
unit VARCHAR(10) NOT NULL DEFAULT 'u',
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
@@ -36,11 +37,15 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa
|
||||
}
|
||||
|
||||
func sendExpoPush(token, title, body string, commandID int, notifType string) {
|
||||
sendExpoPushWithChannel(token, title, body, commandID, notifType, "orders")
|
||||
}
|
||||
|
||||
func sendExpoPushWithChannel(token, title, body string, commandID int, notifType, channelID string) {
|
||||
payload := map[string]interface{}{
|
||||
"to": token,
|
||||
"title": title,
|
||||
"body": body,
|
||||
"channelId": "orders",
|
||||
"channelId": channelID,
|
||||
"data": map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"type": notifType,
|
||||
@@ -71,7 +76,59 @@ func sendExpoPush(token, title, body string, commandID int, notifType string) {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
log.Printf("✅ [EXPO_PUSH] Push envoyé à %s (status: %d)", token, resp.StatusCode)
|
||||
log.Printf("✅ [EXPO_PUSH] Push envoyé à %s (channel: %s, status: %d)", token, channelID, resp.StatusCode)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PUSH TOKEN - LIVREURS (table users)
|
||||
// ============================================
|
||||
|
||||
// SaveUserPushToken sauvegarde le token push d'un livreur dans la table users
|
||||
func (d *Database) SaveUserPushToken(username, token string) error {
|
||||
_, err := d.Exec(`UPDATE users SET push_token = $1 WHERE username = $2`, token, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUserPushToken récupère le token push d'un livreur depuis la table users
|
||||
func (d *Database) GetUserPushToken(username string) (string, error) {
|
||||
var token sql.NullString
|
||||
err := d.QueryRow(`SELECT push_token FROM users WHERE username = $1`, username).Scan(&token)
|
||||
if err != nil || !token.Valid {
|
||||
return "", err
|
||||
}
|
||||
return token.String, nil
|
||||
}
|
||||
|
||||
// DeleteUserPushToken supprime le token push d'un livreur
|
||||
func (d *Database) DeleteUserPushToken(username string) error {
|
||||
_, err := d.Exec(`UPDATE users SET push_token = NULL WHERE username = $1`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
// NotifyLivreur envoie une notification in-app (Redis) + push Expo à un livreur
|
||||
func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error {
|
||||
notifKey := fmt.Sprintf("notifications:%s", username)
|
||||
|
||||
notification := map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"type": notifType,
|
||||
"message": message,
|
||||
"created_at": time.Now().Format(time.RFC3339),
|
||||
"read": false,
|
||||
}
|
||||
|
||||
notifJSON, _ := json.Marshal(notification)
|
||||
Redis.LPush(RedisCtx, notifKey, notifJSON)
|
||||
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
|
||||
|
||||
// Push notification si le livreur a un token enregistré
|
||||
pushToken, err := d.GetUserPushToken(username)
|
||||
if err == nil && pushToken != "" {
|
||||
go sendExpoPushWithChannel(pushToken, "Nouvelle commande assignée", message, commandID, notifType, "deliveries")
|
||||
}
|
||||
|
||||
log.Printf("📬 [LIVREUR_NOTIF] Notification envoyée à %s: %s", username, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDeliveryRating ajoute une note pour un livreur
|
||||
|
||||
@@ -17,7 +17,8 @@ func (db *Database) CreateProduct(product interface{}) error {
|
||||
GetName() string
|
||||
GetCategory() string
|
||||
GetDescription() string
|
||||
GetStock() float64 // ← ajouter méthode pour le stock
|
||||
GetStock() float64
|
||||
GetUnit() string
|
||||
GetPrices() []models.ProductPrice
|
||||
SetID(int)
|
||||
SetCreatedAt(time.Time)
|
||||
@@ -45,15 +46,15 @@ func (db *Database) CreateProduct(product interface{}) error {
|
||||
log.Printf("📦 [DB CreateProduct] Nombre de prix: %d", len(p.GetPrices()))
|
||||
}
|
||||
|
||||
// Insérer le produit avec le stock
|
||||
query := `INSERT INTO products (name, category, description, stock, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at, updated_at`
|
||||
// Insérer le produit avec le stock et l'unité
|
||||
query := `INSERT INTO products (name, category, description, stock, unit, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, created_at, updated_at`
|
||||
|
||||
now := time.Now()
|
||||
var productID int
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := db.QueryRow(query, p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), now, now).
|
||||
err := db.QueryRow(query, p.GetName(), p.GetCategory(), p.GetDescription(), p.GetStock(), p.GetUnit(), now, now).
|
||||
Scan(&productID, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
||||
@@ -93,10 +94,10 @@ func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
|
||||
// ✅ AJOUTER stock dans le SELECT
|
||||
err := d.QueryRow(`
|
||||
SELECT id, name, category, description, stock, created_at, updated_at
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
WHERE id=$1
|
||||
`, id).Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt)
|
||||
`, id).Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.Unit, &p.CreatedAt, &p.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductByID] Erreur query: %v", err)
|
||||
@@ -122,7 +123,7 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
log.Println("📦 [GetAllProducts] START")
|
||||
|
||||
rows, err := d.Query(`
|
||||
SELECT id, name, category, description, stock, created_at, updated_at
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
ORDER BY id ASC
|
||||
`)
|
||||
@@ -135,7 +136,7 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
var products []models.Product
|
||||
for rows.Next() {
|
||||
var p models.Product
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.Unit, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
log.Printf("❌ [GetAllProducts] Erreur scan: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -173,9 +174,8 @@ func (d *Database) GetAllProducts() ([]models.Product, error) {
|
||||
func (db *Database) GetProductsByCategory(category string) ([]models.Product, error) {
|
||||
log.Printf("📦 [GetProductsByCategory] START - Category=%s", category)
|
||||
|
||||
// ✅ AJOUTER stock dans le SELECT
|
||||
rows, err := db.Query(`
|
||||
SELECT id, name, category, description, stock, created_at, updated_at
|
||||
SELECT id, name, category, description, stock, unit, created_at, updated_at
|
||||
FROM products
|
||||
WHERE category = $1
|
||||
ORDER BY created_at DESC
|
||||
@@ -190,8 +190,7 @@ func (db *Database) GetProductsByCategory(category string) ([]models.Product, er
|
||||
var products []models.Product
|
||||
for rows.Next() {
|
||||
var p models.Product
|
||||
// ✅ AJOUTER &p.Stock dans le Scan
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Category, &p.Description, &p.Stock, &p.Unit, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
log.Printf("❌ [GetProductsByCategory] Erreur scan: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors du scan d'un produit: %w", err)
|
||||
}
|
||||
|
||||
@@ -668,6 +668,12 @@ func ForceValidateDelivery(c *gin.Context) {
|
||||
clientUsername, _ := command["username"].(string)
|
||||
livreurAssign, _ := command["livreur_assign"].(string)
|
||||
|
||||
// Notifier le client
|
||||
if clientUsername != "" {
|
||||
clientMsg := fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID)
|
||||
database.NotifyClient(clientUsername, commandID, "livre", clientMsg)
|
||||
}
|
||||
|
||||
if err := database.IncrementClientCommandCount(clientUsername); err != nil {
|
||||
log.Printf("⚠️ Erreur compteur commandes: %v", err)
|
||||
}
|
||||
|
||||
@@ -359,6 +359,31 @@ func UpdateDeliveryStatus(c *gin.Context) {
|
||||
}
|
||||
database.AddCommandLog(commandID, req.Status, message, usernameStr)
|
||||
|
||||
// ✅ NOTIFICATION CLIENT
|
||||
clientUsername, _ := command["username"].(string)
|
||||
if clientUsername != "" {
|
||||
var clientMsg string
|
||||
switch req.Status {
|
||||
case "support":
|
||||
clientMsg = fmt.Sprintf("Votre commande #%d est prise en charge", commandID)
|
||||
case "en_route":
|
||||
if etaMinutes > 0 {
|
||||
clientMsg = fmt.Sprintf("Votre commande #%d est en route ! Arrivée dans ~%d min", commandID, etaMinutes)
|
||||
} else {
|
||||
clientMsg = fmt.Sprintf("Votre commande #%d est en route !", commandID)
|
||||
}
|
||||
case "arrived":
|
||||
clientMsg = fmt.Sprintf("Votre livreur est arrivé pour la commande #%d", commandID)
|
||||
case "livre":
|
||||
clientMsg = fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID)
|
||||
case "failed":
|
||||
clientMsg = fmt.Sprintf("Échec de livraison pour la commande #%d", commandID)
|
||||
}
|
||||
if clientMsg != "" {
|
||||
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ GESTION SPÉCIALE SELON LE STATUT
|
||||
switch req.Status {
|
||||
case "livre":
|
||||
|
||||
@@ -107,6 +107,144 @@ func GetClientNotifications(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterLivreurPushToken enregistre le push token Expo d'un livreur
|
||||
// POST /api/v1/livreur/push-token
|
||||
func RegisterLivreurPushToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
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.SaveUserPushToken(username, req.PushToken); err != nil {
|
||||
log.Printf("❌ [LIVREUR_PUSH_TOKEN] Erreur sauvegarde token pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_PUSH_TOKEN] Token enregistré pour livreur %s", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// UnregisterLivreurPushToken supprime le push token d'un livreur (au logout)
|
||||
// DELETE /api/v1/livreur/push-token
|
||||
func UnregisterLivreurPushToken(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
database := c.MustGet("database").(*db.Database)
|
||||
if err := database.DeleteUserPushToken(username); err != nil {
|
||||
log.Printf("❌ [LIVREUR_PUSH_TOKEN] Erreur suppression token pour %s: %v", username, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_PUSH_TOKEN] Token supprimé pour livreur %s", username)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// GetLivreurNotifications retourne les notifications du livreur connecté
|
||||
// GET /api/v1/livreur/notifications
|
||||
func GetLivreurNotifications(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR_NOTIFICATIONS] Erreur Redis: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
type Notification struct {
|
||||
CommandID int `json:"command_id"`
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
notifications := make([]Notification, 0, len(results))
|
||||
unreadCount := 0
|
||||
|
||||
for _, raw := range results {
|
||||
var n Notification
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
notifications = append(notifications, n)
|
||||
if !n.Read {
|
||||
unreadCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"notifications": notifications,
|
||||
"unread_count": unreadCount,
|
||||
"total": len(notifications),
|
||||
})
|
||||
}
|
||||
|
||||
// MarkLivreurNotificationsRead marque toutes les notifications du livreur comme lues
|
||||
// POST /api/v1/livreur/notifications/read
|
||||
func MarkLivreurNotificationsRead(c *gin.Context) {
|
||||
username := c.GetString("username")
|
||||
if username == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
|
||||
return
|
||||
}
|
||||
|
||||
notifKey := "notifications:" + username
|
||||
|
||||
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [LIVREUR_MARK_READ] Erreur Redis LRange: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
markedCount := 0
|
||||
for i, raw := range results {
|
||||
var n map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &n); err != nil {
|
||||
continue
|
||||
}
|
||||
if read, ok := n["read"].(bool); ok && read {
|
||||
continue
|
||||
}
|
||||
n["read"] = true
|
||||
updated, _ := json.Marshal(n)
|
||||
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
|
||||
markedCount++
|
||||
}
|
||||
|
||||
log.Printf("✅ [LIVREUR_MARK_READ] %d notifications marquées lues pour %s", markedCount, username)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"marked_count": markedCount,
|
||||
})
|
||||
}
|
||||
|
||||
// MarkNotificationsRead marque toutes les notifications comme lues
|
||||
// POST /api/v1/notifications/read
|
||||
func MarkNotificationsRead(c *gin.Context) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/db"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
@@ -352,6 +353,45 @@ func ValidateBasket(c *gin.Context) {
|
||||
|
||||
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
|
||||
|
||||
// ============================================
|
||||
// 1️⃣b Vérifier le minimum de commande selon la zone
|
||||
// ============================================
|
||||
var cartTotal float64
|
||||
for _, item := range items {
|
||||
if price, ok := item["price"].(float64); ok {
|
||||
cartTotal += price
|
||||
}
|
||||
}
|
||||
|
||||
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal)
|
||||
if !zoneResult.OK {
|
||||
if zoneResult.ZoneName == "inconnue" {
|
||||
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Adresse invalide : aucun code postal détecté",
|
||||
})
|
||||
} else if zoneResult.ZoneName == "hors zone" {
|
||||
log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "Livraison non disponible pour ce code postal",
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
})
|
||||
} else {
|
||||
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
|
||||
"zone": zoneResult.ZoneName,
|
||||
"minimum": zoneResult.MinAmount,
|
||||
"cart_total": cartTotal,
|
||||
"missing": zoneResult.MinAmount - cartTotal,
|
||||
"postal_code": zoneResult.PostalCode,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount)
|
||||
|
||||
// ============================================
|
||||
// 2️⃣ Créer la commande (qui décrémente automatiquement le stock)
|
||||
// ============================================
|
||||
@@ -439,6 +479,16 @@ func ValidateBasket(c *gin.Context) {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
|
||||
}
|
||||
|
||||
// Notifier le livreur de la nouvelle commande
|
||||
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
|
||||
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
||||
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
|
||||
}
|
||||
|
||||
// Notifier le client
|
||||
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime)
|
||||
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
|
||||
|
||||
assigned = true
|
||||
assignInfo = gin.H{
|
||||
"username": nearest.Username,
|
||||
|
||||
@@ -114,6 +114,22 @@ func validatePrice(quantity float64, price float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUnit(unit string) error {
|
||||
validUnits := map[string]bool{
|
||||
"u": true, // unité
|
||||
"kg": true, // kilogramme
|
||||
"g": true, // gramme
|
||||
"bag": true, // sac
|
||||
"l": true, // litre
|
||||
"cl": true, // centilitre
|
||||
"pcs": true, // pièces
|
||||
}
|
||||
if !validUnits[unit] {
|
||||
return fmt.Errorf("unité invalide : valeurs acceptées : u, kg, g, bag, l, cl, pcs")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCategory(category string) error {
|
||||
// Nettoyage
|
||||
category = strings.ToLower(strings.TrimSpace(category))
|
||||
@@ -205,6 +221,10 @@ func CreateProduct(c *gin.Context) {
|
||||
category := strings.TrimSpace(c.PostForm("category"))
|
||||
description := strings.TrimSpace(c.PostForm("description"))
|
||||
stockStr := c.PostForm("stock")
|
||||
unit := strings.ToLower(strings.TrimSpace(c.PostForm("unit")))
|
||||
if unit == "" {
|
||||
unit = "u"
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
if err := validateProductName(name); err != nil {
|
||||
@@ -231,6 +251,11 @@ func CreateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateUnit(unit); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// ✅ VALIDER LE STOCK
|
||||
stock, err := strconv.ParseFloat(stockStr, 64)
|
||||
if err != nil {
|
||||
@@ -296,6 +321,7 @@ func CreateProduct(c *gin.Context) {
|
||||
Category: category,
|
||||
Description: description,
|
||||
Stock: stock,
|
||||
Unit: unit,
|
||||
Prices: prices,
|
||||
}
|
||||
|
||||
@@ -581,6 +607,7 @@ func UpdateProduct(c *gin.Context) {
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Stock float64 `json:"stock"`
|
||||
Unit string `json:"unit"`
|
||||
Prices []models.ProductPrice `json:"prices"`
|
||||
}
|
||||
|
||||
@@ -605,6 +632,14 @@ func UpdateProduct(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if updateData.Unit == "" {
|
||||
updateData.Unit = "u"
|
||||
}
|
||||
if err := validateUnit(updateData.Unit); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := validateStock(updateData.Stock); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -627,8 +662,8 @@ func UpdateProduct(c *gin.Context) {
|
||||
// ✅ UPDATE PRODUIT
|
||||
updateQuery := `
|
||||
UPDATE products
|
||||
SET name = $1, category = $2, description = $3, stock = $4, updated_at = $5
|
||||
WHERE id = $6
|
||||
SET name = $1, category = $2, description = $3, stock = $4, unit = $5, updated_at = $6
|
||||
WHERE id = $7
|
||||
`
|
||||
|
||||
_, err = database.Exec(updateQuery,
|
||||
@@ -636,6 +671,7 @@ func UpdateProduct(c *gin.Context) {
|
||||
updateData.Category,
|
||||
updateData.Description,
|
||||
updateData.Stock,
|
||||
updateData.Unit,
|
||||
time.Now(),
|
||||
id,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package handlers
|
||||
|
||||
import "regexp"
|
||||
|
||||
// ============================================================
|
||||
// Zones de livraison — minimum de commande par code postal
|
||||
// ============================================================
|
||||
// Remplis les listes de codes postaux quand tu les as.
|
||||
// Un code postal absent de toutes les zones → commande refusée.
|
||||
// ============================================================
|
||||
|
||||
type deliveryZone struct {
|
||||
Name string
|
||||
MinAmount float64
|
||||
codes map[string]struct{}
|
||||
}
|
||||
|
||||
var deliveryZones = []deliveryZone{
|
||||
{
|
||||
Name: "Zone 30€",
|
||||
MinAmount: 30.0,
|
||||
codes: postalSet([]string{
|
||||
"44000",
|
||||
"44100",
|
||||
"44200",
|
||||
"44300",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "Zone 50€",
|
||||
MinAmount: 50.0,
|
||||
codes: postalSet([]string{
|
||||
"44400", // Rezé
|
||||
"44880", // Les Sorinières / Sautron
|
||||
"44120", // Vertou
|
||||
"44230", // Saint-Sébastien-sur-Loire
|
||||
"44115", // Basse-Goulaine / Haute-Goulaine
|
||||
"44980", // Sainte-Luce-sur-Loire
|
||||
"44470", // Carquefou
|
||||
"44240", // La Chapelle-sur-Erdre
|
||||
"44700", // Orvault
|
||||
"44800", // Saint-Herblain
|
||||
"44340", // Bouguenais
|
||||
"44620", // La Montagne
|
||||
"44830", // Bouaye
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "Zone 100€",
|
||||
MinAmount: 100.0,
|
||||
codes: postalSet([]string{
|
||||
"44860", // Pont-Saint-Martin / Saint-Aignan-Grandlieu
|
||||
"44220", // Couëron
|
||||
"44118", // La Chevrolière
|
||||
"44830", // Brains
|
||||
"44710", // Saint-Léger-les-Vignes
|
||||
"44690", // La Haie-Fouassière
|
||||
"44470", // Mauves-sur-Loire
|
||||
"44240", // Sucé-sur-Erdre
|
||||
"44119", // Grandchamp-des-Fontaines
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
|
||||
|
||||
// postalSet convertit une slice de codes en set pour lookup O(1).
|
||||
func postalSet(codes []string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(codes))
|
||||
for _, c := range codes {
|
||||
m[c] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// extractPostalCode extrait le premier code postal à 5 chiffres d'une adresse.
|
||||
func extractPostalCode(address string) string {
|
||||
m := postalCodeRe.FindStringSubmatch(address)
|
||||
if len(m) < 2 {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
// zoneCheckResult est le résultat de la vérification de zone.
|
||||
type zoneCheckResult struct {
|
||||
PostalCode string
|
||||
ZoneName string
|
||||
MinAmount float64
|
||||
OK bool
|
||||
}
|
||||
|
||||
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
|
||||
// Code postal introuvable → OK = false (refus).
|
||||
// Code postal hors de toutes les zones → OK = false (refus).
|
||||
func checkDeliveryZone(deliveryAddress string, total float64) zoneCheckResult {
|
||||
code := extractPostalCode(deliveryAddress)
|
||||
if code == "" {
|
||||
return zoneCheckResult{
|
||||
PostalCode: "",
|
||||
ZoneName: "inconnue",
|
||||
MinAmount: 0,
|
||||
OK: false,
|
||||
}
|
||||
}
|
||||
|
||||
for _, zone := range deliveryZones {
|
||||
if _, found := zone.codes[code]; found {
|
||||
return zoneCheckResult{
|
||||
PostalCode: code,
|
||||
ZoneName: zone.Name,
|
||||
MinAmount: zone.MinAmount,
|
||||
OK: total >= zone.MinAmount,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return zoneCheckResult{
|
||||
PostalCode: code,
|
||||
ZoneName: "hors zone",
|
||||
MinAmount: 0,
|
||||
OK: false,
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,9 @@ func main() {
|
||||
|
||||
// Configuration CORS
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"https://uber-stup.club", "http://localhost:5173"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||
AllowOrigins: []string{"https://uber-stup.club", "https://mln-uber.club", "http://localhost:5173"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Request-ID"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
@@ -9,7 +9,11 @@ import (
|
||||
)
|
||||
|
||||
func OrderHoursMiddleware(c *gin.Context) {
|
||||
now := time.Now()
|
||||
loc, err := time.LoadLocation("Europe/Paris")
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
now := time.Now().In(loc)
|
||||
hour := now.Hour()
|
||||
min := now.Minute()
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ type Product struct {
|
||||
Category string `json:"category" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Stock float64 `json:"stock"` // ← ajouter le stock ici
|
||||
Unit string `json:"unit"` // kg | g | bag | l | cl | pcs | u
|
||||
Prices []ProductPrice `json:"prices"`
|
||||
Media []Media `json:"media,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -38,7 +39,8 @@ type StockInfo struct {
|
||||
func (p *Product) GetName() string { return p.Name }
|
||||
func (p *Product) GetCategory() string { return p.Category }
|
||||
func (p *Product) GetDescription() string { return p.Description }
|
||||
func (p *Product) GetStock() float64 { return p.Stock } // ← méthode pour le stock
|
||||
func (p *Product) GetStock() float64 { return p.Stock }
|
||||
func (p *Product) GetUnit() string { return p.Unit }
|
||||
func (p *Product) GetPrices() []ProductPrice { return p.Prices }
|
||||
func (p *Product) SetID(id int) { p.ID = id }
|
||||
func (p *Product) SetCreatedAt(t time.Time) { p.CreatedAt = t }
|
||||
|
||||
@@ -157,7 +157,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
adminGroupV2.POST("/products", handlers.CreateProduct)
|
||||
adminGroupV2.GET("/products", handlers.GetAllProducts)
|
||||
adminGroupV2.GET("/products/:id", handlers.GetProductByID)
|
||||
adminGroupV2.PUT("/products/update/:id", handlers.UpdateProduct)
|
||||
adminGroupV2.PUT("/products/:id", handlers.UpdateProduct)
|
||||
adminGroupV2.DELETE("/products/:id", handlers.DeleteProduct)
|
||||
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
|
||||
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
|
||||
@@ -292,6 +292,14 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
||||
livreurGroupV1.DELETE("/alert/:id", handlers.EndAlert) // Mettre fin à une alerte
|
||||
livreurGroupV1.GET("/alerts", handlers.GetMyAlerts) // Voir mes alertes
|
||||
livreurGroupV1.GET("/alert/:id", handlers.GetAlert) // Détail d'une alerte
|
||||
|
||||
// ============================================
|
||||
// NOTIFICATIONS LIVREUR
|
||||
// ============================================
|
||||
livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
|
||||
livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
|
||||
livreurGroupV1.POST("/push-token", handlers.RegisterLivreurPushToken)
|
||||
livreurGroupV1.DELETE("/push-token", handlers.UnregisterLivreurPushToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,20 @@ func tryAssignCommandWithPriority(
|
||||
|
||||
database.AddCommandLog(commandID, "assigned", logMessage, "system-cron")
|
||||
|
||||
// 8. Notifier le livreur de la nouvelle commande
|
||||
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
|
||||
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
|
||||
log.Printf("⚠️ [CRON] Erreur notification livreur %s: %v", nearest.Username, notifErr)
|
||||
}
|
||||
|
||||
// 9. Notifier le client
|
||||
if cmd, err := database.GetCommandByID(commandID); err == nil {
|
||||
if clientUsername, ok := cmd["username"].(string); ok && clientUsername != "" {
|
||||
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime)
|
||||
database.NotifyClient(clientUsername, commandID, "assigned", clientMsg)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CRON] Cmd %d → %s (%.2f km) | Priorité #%d | Attente: %d min",
|
||||
commandID, nearest.Username, distance, priority, waitingMinutes)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user