chore: add ansible backend docker frontend-prep
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
)
|
||||
|
||||
func (d *Database) CreateAlert(username string) (models.AlertPolicy, error) {
|
||||
|
||||
query := `
|
||||
INSERT INTO alerte_policy (username, status)
|
||||
VALUES ($1, 'true')
|
||||
RETURNING id, username, status, created_at, updated_at
|
||||
`
|
||||
var alert models.AlertPolicy
|
||||
err := d.QueryRow(query, username).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
|
||||
if err != nil {
|
||||
return models.AlertPolicy{}, err
|
||||
}
|
||||
|
||||
return alert, nil
|
||||
|
||||
}
|
||||
|
||||
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
|
||||
|
||||
query := `
|
||||
SELECT id, username, status, created_at, updated_at
|
||||
FROM alerte_policy
|
||||
WHERE id = $1
|
||||
`
|
||||
var alert models.AlertPolicy
|
||||
err := d.QueryRow(query, id).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
|
||||
if err != nil {
|
||||
return models.AlertPolicy{}, err
|
||||
}
|
||||
|
||||
return alert, nil
|
||||
|
||||
}
|
||||
|
||||
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
|
||||
|
||||
query := `
|
||||
SELECT id, username, status, created_at, updated_at
|
||||
FROM alerte_policy
|
||||
`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var alerts []models.AlertPolicy
|
||||
for rows.Next() {
|
||||
var alert models.AlertPolicy
|
||||
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
|
||||
return alerts, nil
|
||||
|
||||
}
|
||||
|
||||
func (d *Database) DeleteAlertPolicy(id int) error {
|
||||
|
||||
query := `
|
||||
UPDATE alerte_policy
|
||||
SET status = 'false', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`
|
||||
_, err := d.Exec(query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// EndAlert met fin à une alerte en changeant son statut à 'false'
|
||||
func (d *Database) EndAlert(id int) error {
|
||||
|
||||
query := `
|
||||
UPDATE alerte_policy
|
||||
SET status = 'false', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND status = 'true'
|
||||
`
|
||||
result, err := d.Exec(query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("alerte non trouvée ou déjà terminée")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivateAlert active une alerte en changeant son statut à 'true'
|
||||
func (d *Database) ActivateAlert(id int) error {
|
||||
|
||||
query := `
|
||||
UPDATE alerte_policy
|
||||
SET status = 'true', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND status = 'false'
|
||||
`
|
||||
result, err := d.Exec(query, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("alerte non trouvée ou déjà active")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveAlerts récupère toutes les alertes actives (status = 'true')
|
||||
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
|
||||
|
||||
query := `
|
||||
SELECT id, username, status, created_at, updated_at
|
||||
FROM alerte_policy
|
||||
WHERE status = 'true'
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var alerts []models.AlertPolicy
|
||||
for rows.Next() {
|
||||
var alert models.AlertPolicy
|
||||
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
|
||||
return alerts, nil
|
||||
}
|
||||
|
||||
// GetAlertsByUsername récupère toutes les alertes d'un livreur
|
||||
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
|
||||
|
||||
query := `
|
||||
SELECT id, username, status, created_at, updated_at
|
||||
FROM alerte_policy
|
||||
WHERE username = $1
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
rows, err := d.Query(query, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var alerts []models.AlertPolicy
|
||||
for rows.Next() {
|
||||
var alert models.AlertPolicy
|
||||
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts = append(alerts, alert)
|
||||
}
|
||||
|
||||
return alerts, nil
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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
|
||||
var productID int
|
||||
productQuery := `SELECT id FROM products WHERE name = $1 AND category = $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)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la recherche du produit: %w", err)
|
||||
}
|
||||
|
||||
// Récupérer le prix correct selon la quantité
|
||||
price, err := d.GetProductPrice(nameProduct, category, quantity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération prix: %w", err)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
if err == nil {
|
||||
// Produit déjà dans le panier : mettre à jour quantité et prix
|
||||
newQuantity := existingQuantity + quantity
|
||||
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(
|
||||
&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
|
||||
}
|
||||
|
||||
// Produit non présent : l'ajouter
|
||||
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
|
||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||
var price float64
|
||||
|
||||
query := `
|
||||
SELECT price
|
||||
FROM product_prices pp
|
||||
INNER JOIN products p ON pp.product_id = p.id
|
||||
WHERE LOWER(p.name) = LOWER($1)
|
||||
AND LOWER(p.category) = LOWER($2)
|
||||
AND pp.quantity <= $3
|
||||
ORDER BY pp.quantity DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
err := d.QueryRow(query, name, category, quantity).Scan(&price)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("prix produit introuvable pour %f %s: %w", quantity, name, err)
|
||||
}
|
||||
|
||||
return price, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductStock(name, category string) (float64, error) {
|
||||
var stock float64
|
||||
query := `SELECT stock FROM products WHERE name = $1 AND category = $2`
|
||||
err := d.QueryRow(query, name, category).Scan(&stock)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("produit non trouvé: %w", err)
|
||||
}
|
||||
return stock, nil
|
||||
}
|
||||
|
||||
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`
|
||||
result, err := d.Exec(query, quantity, name, category)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("stock insuffisant pour le produit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllProductsInBasket récupère tous les produits du panier d'un utilisateur
|
||||
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
|
||||
query := `SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.created_at,
|
||||
p.name, p.category, p.description
|
||||
FROM baskets b
|
||||
INNER JOIN products p ON b.product_id = p.id
|
||||
WHERE b.username = $1
|
||||
ORDER BY b.created_at DESC`
|
||||
|
||||
rows, err := d.Query(query, username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du panier: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var baskets []models.Panier
|
||||
for rows.Next() {
|
||||
var basket models.Panier
|
||||
|
||||
err := rows.Scan(
|
||||
&basket.ID,
|
||||
&basket.Username,
|
||||
&basket.ProductID, // ✔ FIX MAJEUR
|
||||
&basket.Quantity,
|
||||
&basket.Price,
|
||||
&basket.CreatedAt,
|
||||
&basket.ProductName,
|
||||
&basket.Category,
|
||||
&basket.Description,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan du panier: %w", err)
|
||||
}
|
||||
|
||||
baskets = append(baskets, basket)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
|
||||
}
|
||||
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// DecrementProductStockByID décrémente le stock d'un produit par son ID de manière sécurisée (évite race condition)
|
||||
func (d *Database) DecrementProductStockByID(productID int, quantity float64) error {
|
||||
query := `
|
||||
UPDATE products
|
||||
SET stock = stock - $1
|
||||
WHERE id = $2
|
||||
AND stock >= $1
|
||||
`
|
||||
result, err := d.Exec(query, quantity, productID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du stock: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification du stock affecté: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d", productID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProductFromBasket supprime un produit spécifique du panier
|
||||
func (d *Database) DeleteProductFromBasket(basketID int) error {
|
||||
query := `DELETE FROM baskets WHERE id = $1`
|
||||
result, err := d.Exec(query, basketID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du produit: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearBasket vide complètement le panier d'un utilisateur
|
||||
func (d *Database) ClearBasket(username string) error {
|
||||
query := `DELETE FROM baskets WHERE username = $1`
|
||||
_, err := d.Exec(query, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors du vidage du panier: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetBasketTotal calcule le montant total du panier d'un utilisateur
|
||||
func (d *Database) GetBasketTotal(username string) (float64, error) {
|
||||
query := `SELECT COALESCE(SUM(quantity * price), 0) as total
|
||||
FROM baskets
|
||||
WHERE username = $1`
|
||||
|
||||
var total float64
|
||||
err := d.QueryRow(query, username).Scan(&total)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lors du calcul du total: %w", err)
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// GetBasketItemCount compte le nombre d'items dans le panier
|
||||
func (d *Database) GetBasketItemCount(username string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM baskets WHERE username = $1`
|
||||
|
||||
var count int
|
||||
err := d.QueryRow(query, username).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur lors du comptage des items: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// UpdateBasketItemQuantity met à jour la quantité d'un item du panier
|
||||
func (d *Database) UpdateBasketItemQuantity(basketID int, quantity float64) error {
|
||||
if quantity <= 0 {
|
||||
return fmt.Errorf("la quantité doit être supérieure à 0")
|
||||
}
|
||||
|
||||
query := `UPDATE baskets SET quantity = $1, created_at = CURRENT_TIMESTAMP WHERE id = $2`
|
||||
result, err := d.Exec(query, quantity, basketID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de la quantité: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé dans le panier")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExtendBasketReservations prolonge les réservations
|
||||
func (d *Database) ExtendBasketReservations(username string) error {
|
||||
query := `SELECT product_id, quantity FROM baskets WHERE username = $1`
|
||||
rows, err := d.Query(query, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur récupération panier: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type Item struct {
|
||||
ProductID int
|
||||
Quantity int
|
||||
}
|
||||
|
||||
var items []Item
|
||||
for rows.Next() {
|
||||
var item Item
|
||||
if err := rows.Scan(&item.ProductID, &item.Quantity); err != nil {
|
||||
return fmt.Errorf("erreur scan: %w", err)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
// Vérifier stock disponible pour chaque item
|
||||
for _, item := range items {
|
||||
var stock int
|
||||
err := d.QueryRow(`SELECT stock FROM products WHERE id = $1`, item.ProductID).Scan(&stock)
|
||||
if err != nil {
|
||||
return fmt.Errorf("produit %d non trouvé: %w", item.ProductID, err)
|
||||
}
|
||||
|
||||
if stock < item.Quantity {
|
||||
return fmt.Errorf("stock insuffisant pour le produit %d (demandé: %d, disponible: %d)",
|
||||
item.ProductID, item.Quantity, stock)
|
||||
}
|
||||
}
|
||||
|
||||
// Prolonger les réservations de 15 minutes
|
||||
newReservation := time.Now().Add(15 * time.Minute)
|
||||
updateQuery := `UPDATE baskets
|
||||
SET reserved_until = $1
|
||||
WHERE username = $2`
|
||||
|
||||
_, err = d.Exec(updateQuery, newReservation, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur prolongation: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Réservations prolongées pour %s jusqu'à %s",
|
||||
username, newReservation.Format("15:04:05"))
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckBasketReservations vérifie si les réservations sont expirées
|
||||
func (d *Database) CheckBasketReservations(username string) (bool, error) {
|
||||
query := `SELECT COUNT(*) FROM baskets
|
||||
WHERE username = $1
|
||||
AND (reserved_until IS NULL OR reserved_until < CURRENT_TIMESTAMP)`
|
||||
|
||||
var expiredCount int
|
||||
err := d.QueryRow(query, username).Scan(&expiredCount)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return expiredCount > 0, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetBasketItems(username string) ([]map[string]interface{}, error) {
|
||||
query := `SELECT product_id, quantity, price FROM baskets WHERE username = $1`
|
||||
rows, err := d.Query(query, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var productID, quantity int
|
||||
var price float64
|
||||
if err := rows.Scan(&productID, &quantity, &price); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, map[string]interface{}{
|
||||
"product_id": productID,
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
// ============================================
|
||||
// db/cancel_commands_db.go
|
||||
// FONCTIONS DB ATOMIQUES POUR L'ANNULATION
|
||||
// VERSION 100% SÉCURISÉE - FIX ETA CHECK
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// ANNULATION ATOMIQUE
|
||||
// ============================================
|
||||
|
||||
func (d *Database) CancelCommandAtomic(commandID int, username, reason string, force bool) (int, map[string]int, error) {
|
||||
log.Printf("🔒 [CancelAtomic] START - cmd=%d, user=%s, force=%v", commandID, username, force)
|
||||
|
||||
// ✅ TRANSACTION
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return 0, nil, fmt.Errorf("erreur transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// ✅ SELECT FOR UPDATE - Verrouiller la ligne
|
||||
var currentStatus, cmdUsername, livreurAssign string
|
||||
err = tx.QueryRow(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '')
|
||||
FROM commandes
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, commandID).Scan(¤tStatus, &cmdUsername, &livreurAssign)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, nil, fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
log.Printf("📋 [CancelAtomic] Trouvée - status=%s, owner=%s, livreur=%s", currentStatus, cmdUsername, livreurAssign)
|
||||
|
||||
// ✅ VÉRIFIER PROPRIÉTÉ
|
||||
if cmdUsername != username {
|
||||
return 0, nil, fmt.Errorf("commande ne vous appartient pas")
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER STATUT
|
||||
nonCancellableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
|
||||
for _, s := range nonCancellableStatuses {
|
||||
if currentStatus == s {
|
||||
return 0, nil, fmt.Errorf("impossible d'annuler")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ FIX: DÉTECTION CORRECTE DE L'ANNULATION TARDIVE
|
||||
// ============================================
|
||||
// Une annulation est tardive UNIQUEMENT si :
|
||||
// 1. Un livreur est assigné
|
||||
// 2. Le statut est "en_route" (livreur parti) OU "arrived" (livreur arrivé)
|
||||
// 3. OU une ETA a été définie (ce qui signifie que le livreur est en route)
|
||||
|
||||
isLateCancel := false
|
||||
|
||||
if livreurAssign != "" {
|
||||
// Cas 1: Statut en_route ou arrived = toujours tardif
|
||||
if currentStatus == "en_route" || currentStatus == "arrived" {
|
||||
isLateCancel = true
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - Statut: %s", currentStatus)
|
||||
} else {
|
||||
// Cas 2: Pour les autres statuts, vérifier si une ETA existe
|
||||
hasRealETA := d.CheckCommandETAExistsAndValid(commandID)
|
||||
if hasRealETA {
|
||||
isLateCancel = true
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - ETA définie")
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Statut: %s, Pas d'ETA valide", currentStatus)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Aucun livreur assigné")
|
||||
}
|
||||
|
||||
// ✅ SI ANNULATION TARDIVE SANS CONFIRMATION
|
||||
if isLateCancel && !force {
|
||||
return 0, nil, fmt.Errorf("confirmation requise")
|
||||
}
|
||||
|
||||
// ✅ UPDATE STATUT (avec vérification pour éviter race condition)
|
||||
result, err := tx.Exec(`
|
||||
UPDATE commandes
|
||||
SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1 AND status = $2 AND username = $3
|
||||
`, commandID, currentStatus, username)
|
||||
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return 0, nil, fmt.Errorf("commande déjà modifiée")
|
||||
}
|
||||
|
||||
log.Printf("✅ [CancelAtomic] Statut mis à jour: %s → cancelled", currentStatus)
|
||||
|
||||
// ✅ REMBOURSER LE STOCK ATOMIQUEMENT
|
||||
_, err = tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = $1 AND ci.product_id = p.id
|
||||
`, commandID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur remboursement stock: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [CancelAtomic] Stock remboursé")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPLIQUER PÉNALITÉ SI ANNULATION TARDIVE
|
||||
// ============================================
|
||||
penalty := 0
|
||||
pointsLost := map[string]int{"weed": 0, "zipette": 0}
|
||||
|
||||
if isLateCancel && force {
|
||||
log.Printf("⚠️ [CancelAtomic] Annulation tardive confirmée - Application pénalité")
|
||||
|
||||
// ✅ RÉCUPÉRER LES POINTS ACTUELS
|
||||
var currentPointsWeed, currentPointsZipette int
|
||||
err := tx.QueryRow(`
|
||||
SELECT point, point_zipette FROM clients WHERE username = $1
|
||||
`, username).Scan(¤tPointsWeed, ¤tPointsZipette)
|
||||
|
||||
if err == nil {
|
||||
pointsLost["weed"] = currentPointsWeed
|
||||
pointsLost["zipette"] = currentPointsZipette
|
||||
|
||||
// ✅ CALCULER PÉNALITÉ
|
||||
penalty, _ = d.CalculateCancellationPenalty(username)
|
||||
|
||||
// ✅ APPLIQUER: Remettre points à 0 + Ajouter pénalité + Incrémenter compteur
|
||||
_, err = tx.Exec(`
|
||||
UPDATE clients
|
||||
SET point = 0,
|
||||
point_zipette = 0,
|
||||
amende = amende + $1,
|
||||
cancellations_count = COALESCE(cancellations_count, 0) + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $2
|
||||
`, penalty, username)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [CancelAtomic] Erreur pénalité: %v", err)
|
||||
} else {
|
||||
log.Printf("⚠️ [CancelAtomic] Pénalité: %d pts, Points perdus: weed=%d, zipette=%d",
|
||||
penalty, pointsLost["weed"], pointsLost["zipette"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ LOG
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||
`, commandID, "cancelled",
|
||||
fmt.Sprintf("Annulée par %s - Raison: %s", username, reason),
|
||||
username)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur log: %v", err)
|
||||
}
|
||||
|
||||
// ✅ COMMIT
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, nil, fmt.Errorf("erreur commit: %w", err)
|
||||
}
|
||||
|
||||
// ✅ NETTOYER QUEUE (async, après commit)
|
||||
if livreurAssign != "" {
|
||||
go func() {
|
||||
err := d.CleanupCompletedCommandFromQueue(commandID, livreurAssign)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CancelAtomic] Erreur cleanup queue: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ✅ INVALIDER CACHES (async)
|
||||
go func() {
|
||||
Redis.Del(RedisCtx,
|
||||
fmt.Sprintf("command:%d", commandID),
|
||||
fmt.Sprintf("client:%s", username),
|
||||
fmt.Sprintf("client:%s:commands", username),
|
||||
)
|
||||
}()
|
||||
|
||||
log.Printf("🎉 [CancelAtomic] SUCCÈS - Commande %d annulée", commandID)
|
||||
|
||||
return penalty, pointsLost, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ NOUVELLE FONCTION: CHECK ETA VALIDE
|
||||
// ============================================
|
||||
|
||||
// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée)
|
||||
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
// Récupérer l'ETA depuis Redis
|
||||
etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
// Parser l'ETA
|
||||
var etaMinutes int
|
||||
_, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes)
|
||||
if err != nil || etaMinutes <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr)
|
||||
return false
|
||||
}
|
||||
|
||||
// Vérifier le TTL (si l'ETA existe, elle doit avoir un TTL)
|
||||
ttl, err := Redis.TTL(RedisCtx, etaKey).Result()
|
||||
if err != nil || ttl <= 0 {
|
||||
log.Printf("⚠️ [CheckETA] ETA expirée pour cmd %d", commandID)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("✅ [CheckETA] ETA valide trouvée pour cmd %d: %d min (TTL: %v)", commandID, etaMinutes, ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SUPPRESSION ATOMIQUE
|
||||
// ============================================
|
||||
|
||||
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
|
||||
log.Printf("🔒 [DeleteAtomic] START - cmd=%d, by=%s (%s)", commandID, deletedBy, role)
|
||||
|
||||
// ✅ TRANSACTION
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// ✅ SELECT FOR UPDATE
|
||||
var currentStatus, cmdUsername, livreurAssign string
|
||||
err = tx.QueryRow(`
|
||||
SELECT status, username, COALESCE(livreur_assign, '')
|
||||
FROM commandes
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, commandID).Scan(¤tStatus, &cmdUsername, &livreurAssign)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", currentStatus, cmdUsername)
|
||||
|
||||
// ✅ REMBOURSER STOCK ATOMIQUEMENT
|
||||
_, err = tx.Exec(`
|
||||
UPDATE products p
|
||||
SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP
|
||||
FROM command_items ci
|
||||
WHERE ci.command_id = $1 AND ci.product_id = p.id
|
||||
`, commandID)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ [DeleteAtomic] Stock remboursé")
|
||||
}
|
||||
|
||||
// ✅ LOG AVANT SUPPRESSION
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||
`, commandID, "deleted",
|
||||
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, currentStatus),
|
||||
deletedBy)
|
||||
|
||||
// ✅ SUPPRIMER ITEMS
|
||||
_, err = tx.Exec(`DELETE FROM command_items WHERE command_id = $1`, commandID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ SUPPRIMER COMMANDE
|
||||
result, err := tx.Exec(`DELETE FROM commandes WHERE id = $1`, commandID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteAtomic] Supprimée de la DB")
|
||||
|
||||
// ✅ COMMIT
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("erreur commit: %w", err)
|
||||
}
|
||||
|
||||
// ✅ NETTOYER QUEUES (async)
|
||||
if livreurAssign != "" {
|
||||
go d.RemoveCommandFromAllQueues(commandID, livreurAssign)
|
||||
}
|
||||
|
||||
// ✅ INVALIDER CACHES (async)
|
||||
go func() {
|
||||
Redis.Del(RedisCtx,
|
||||
fmt.Sprintf("command:%d", commandID),
|
||||
fmt.Sprintf("client:%s", cmdUsername),
|
||||
fmt.Sprintf("client:%s:commands", cmdUsername),
|
||||
)
|
||||
}()
|
||||
|
||||
log.Printf("🎉 [DeleteAtomic] SUCCÈS - Commande %d supprimée", commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// FONCTIONS HELPERS (déjà sécurisées)
|
||||
// ============================================
|
||||
|
||||
func (d *Database) GetCommandPositionInQueue(livreurUsername string, commandID int) (int, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", livreurUsername)
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("commande non trouvée dans la queue")
|
||||
}
|
||||
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetCancelledCommands(username string, limit int) ([]map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT id, username, status, adresse, total_prix, created_at, updated_at
|
||||
FROM commandes
|
||||
WHERE status = 'cancelled'
|
||||
`
|
||||
|
||||
args := []interface{}{}
|
||||
argPos := 1
|
||||
|
||||
if username != "" {
|
||||
query += fmt.Sprintf(" AND username = $%d", argPos)
|
||||
args = append(args, username)
|
||||
argPos++
|
||||
}
|
||||
|
||||
query += " ORDER BY updated_at DESC"
|
||||
|
||||
if limit > 0 {
|
||||
query += fmt.Sprintf(" LIMIT $%d", argPos)
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
rows, err := d.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var commands []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username, status, adresse string
|
||||
var totalPrix float64
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commands = append(commands, map[string]interface{}{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"status": status,
|
||||
"adresse": adresse,
|
||||
"total_prix": totalPrix,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GESTION DES PÉNALITÉS
|
||||
// ============================================
|
||||
|
||||
// AddClientPenalty ajoute une pénalité à un client
|
||||
func (d *Database) AddClientPenalty(username string, points int) error {
|
||||
log.Printf("⚠️ [AddPenalty] Ajout pénalité: %d points pour client %s", points, username)
|
||||
|
||||
// ✅ Validation
|
||||
if points <= 0 {
|
||||
return fmt.Errorf("points invalides: %d", points)
|
||||
}
|
||||
|
||||
if username == "" {
|
||||
return fmt.Errorf("username vide")
|
||||
}
|
||||
|
||||
// ✅ UPDATE dans PostgreSQL
|
||||
query := `UPDATE clients
|
||||
SET amende = amende + $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $2`
|
||||
|
||||
result, err := d.Exec(query, points, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [AddPenalty] Erreur UPDATE: %v", err)
|
||||
return fmt.Errorf("erreur ajout pénalité: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé: %s", username)
|
||||
}
|
||||
|
||||
log.Printf("✅ [AddPenalty] Pénalité ajoutée: +%d points pour %s", points, username)
|
||||
|
||||
// ✅ Invalider le cache Redis du client
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (d *Database) CreateClient(client *models.Client) error {
|
||||
query := `INSERT INTO clients (username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0.0, CURRENT_TIMESTAMP)
|
||||
RETURNING id, created_at`
|
||||
|
||||
err := d.QueryRow(query, client.Username, client.Password, client.Nom, client.Prenom, client.Telephone).Scan(
|
||||
&client.ID,
|
||||
&client.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la création du client: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Client créé avec succès: %s %s (ID: %d)", client.Prenom, client.Nom, client.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientByID récupère un client par son ID
|
||||
func (d *Database) GetClientByID(id int) (*models.Client, error) {
|
||||
var client models.Client
|
||||
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at
|
||||
FROM clients WHERE id = $1`
|
||||
|
||||
err := d.QueryRow(query, id).Scan(
|
||||
&client.ID,
|
||||
&client.Username,
|
||||
&client.Password,
|
||||
&client.Nom,
|
||||
&client.Prenom,
|
||||
&client.Telephone,
|
||||
&client.Command,
|
||||
&client.Point,
|
||||
&client.PointZipette,
|
||||
&client.Amende,
|
||||
&client.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("client non trouvé")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
// GetAllClients récupère tous les clients
|
||||
func (d *Database) GetAllClients() ([]*models.Client, error) {
|
||||
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at
|
||||
FROM clients ORDER BY created_at DESC`
|
||||
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des clients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var clients []*models.Client
|
||||
for rows.Next() {
|
||||
client := &models.Client{}
|
||||
err := rows.Scan(
|
||||
&client.ID,
|
||||
&client.Username,
|
||||
&client.Password,
|
||||
&client.Nom,
|
||||
&client.Prenom,
|
||||
&client.Telephone,
|
||||
&client.Command,
|
||||
&client.Point,
|
||||
&client.PointZipette,
|
||||
&client.Amende,
|
||||
&client.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan du client: %w", err)
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// UpdateClient met à jour un client existant
|
||||
func (d *Database) UpdateClient(client *models.Client) error {
|
||||
query := `UPDATE clients
|
||||
SET username = $1, password = $2, nom = $3, prenom = $4, telephone = $5,
|
||||
command = $6, point = $7, point_zipette = $8, amende = $9
|
||||
WHERE id = $10`
|
||||
|
||||
result, err := d.Exec(query,
|
||||
client.Username,
|
||||
client.Password,
|
||||
client.Nom,
|
||||
client.Prenom,
|
||||
client.Telephone,
|
||||
client.Command,
|
||||
client.Point,
|
||||
client.PointZipette,
|
||||
client.Amende,
|
||||
client.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du client: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Client mis à jour: %s (ID: %d)", client.Username, client.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteClient supprime un client
|
||||
func (d *Database) DeleteClient(id int) error {
|
||||
// ✅ MODIFIÉ : Supprimer tous les tokens du client avec le user_type "client"
|
||||
_ = d.RevokeAllUserTokens(id, "client")
|
||||
|
||||
query := `DELETE FROM clients WHERE id = $1`
|
||||
|
||||
result, err := d.Exec(query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression du client: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Client supprimé (ID: %d)", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateClientPassword met à jour le mot de passe d'un client
|
||||
func (d *Database) UpdateClientPassword(clientID int, hashedPassword string) error {
|
||||
query := `UPDATE clients SET password = $1 WHERE id = $2`
|
||||
|
||||
result, err := d.Exec(query, hashedPassword, clientID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour du mot de passe: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Mot de passe client mis à jour (ID: %d)", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientStats récupère les statistiques d'un client
|
||||
func (d *Database) GetClientStats(clientID int) (map[string]interface{}, error) {
|
||||
client, err := d.GetClientByID(clientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compter les commandes du client
|
||||
var totalCommands, pendingCommands, completedCommands int
|
||||
|
||||
countQuery := `SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'pending' OR status = 'support' OR status = 'livre' THEN 1 ELSE 0 END) as pending,
|
||||
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as completed
|
||||
FROM commandes WHERE username = $1`
|
||||
|
||||
err = d.QueryRow(countQuery, client.Username).Scan(&totalCommands, &pendingCommands, &completedCommands)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur calcul stats: %v", err)
|
||||
totalCommands, pendingCommands, completedCommands = 0, 0, 0
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"id": clientID,
|
||||
"username": client.Username,
|
||||
"nom": client.Nom,
|
||||
"prenom": client.Prenom,
|
||||
"telephone": client.Telephone,
|
||||
"total_commands": totalCommands,
|
||||
"pending_commands": pendingCommands,
|
||||
"completed_commands": completedCommands,
|
||||
"points": client.Point,
|
||||
"points_zipette": client.PointZipette,
|
||||
"amende": client.Amende,
|
||||
"member_since": client.CreatedAt,
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientAmende(username string) (float64, error) {
|
||||
var amende float64
|
||||
query := `SELECT COALESCE(amende, 0) FROM clients WHERE username = $1`
|
||||
|
||||
err := d.QueryRow(query, username).Scan(&amende)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetClientAmende] Erreur pour %s: %v", username, err)
|
||||
return 0, fmt.Errorf("erreur récupération pénalités: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("💰 [GetClientAmende] Client %s: %.2f points", username, amende)
|
||||
return amende, nil
|
||||
}
|
||||
|
||||
func (d *Database) PayClientPenalties(username string, amountPaid float64) error {
|
||||
log.Printf("💳 [PayClientPenalties] Paiement de %.2f points pour %s", amountPaid, username)
|
||||
|
||||
// Vérifier le montant actuel
|
||||
currentAmount, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if currentAmount <= 0 {
|
||||
return fmt.Errorf("aucune pénalité à payer")
|
||||
}
|
||||
|
||||
if amountPaid < currentAmount {
|
||||
return fmt.Errorf("montant insuffisant: %.2f payé, %.2f requis", amountPaid, currentAmount)
|
||||
}
|
||||
|
||||
// Réinitialiser les pénalités
|
||||
query := `UPDATE clients
|
||||
SET amende = 0, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $1`
|
||||
|
||||
result, err := d.Exec(query, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [PayClientPenalties] Erreur UPDATE: %v", err)
|
||||
return fmt.Errorf("erreur paiement pénalités: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [PayClientPenalties] Pénalités réglées pour %s", username)
|
||||
|
||||
// Invalider le cache Redis du client
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementClientCommandCount incrémente le compteur de commandes du client
|
||||
func (d *Database) IncrementClientCommandCount(username string) error {
|
||||
query := `UPDATE clients
|
||||
SET command = command + 1
|
||||
WHERE username = $1`
|
||||
|
||||
result, err := d.Exec(query, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de l'incrémentation du compteur: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ NOUVELLE FONCTION: Ajouter des points selon la catégorie
|
||||
func (d *Database) AddClientPointsByCategory(username string, points int, category string) error {
|
||||
var query string
|
||||
|
||||
if category == "zipette&co" {
|
||||
query = `UPDATE clients
|
||||
SET point_zipette = point_zipette + $1
|
||||
WHERE username = $2`
|
||||
log.Printf("🎁 [ADD_POINTS] Ajout de %d points ZIPETTE à %s", points, username)
|
||||
} else {
|
||||
query = `UPDATE clients
|
||||
SET point = point + $1
|
||||
WHERE username = $2`
|
||||
log.Printf("🎁 [ADD_POINTS] Ajout de %d points WEED/HASH à %s", points, username)
|
||||
}
|
||||
|
||||
result, err := d.Exec(query, points, username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de l'ajout de points: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ %d points (%s) ajoutés au client %s (EN DB)", points, category, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ ANCIENNE FONCTION CONSERVÉE POUR COMPATIBILITÉ (utilise weed/hash par défaut)
|
||||
func (d *Database) AddClientPoints(username string, points int) error {
|
||||
return d.AddClientPointsByCategory(username, points, "weed_hash")
|
||||
}
|
||||
|
||||
func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error) {
|
||||
client := &models.Client{}
|
||||
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at
|
||||
FROM clients WHERE telephone = $1`
|
||||
|
||||
err := d.QueryRow(query, telephone).Scan(
|
||||
&client.ID,
|
||||
&client.Username,
|
||||
&client.Password,
|
||||
&client.Nom,
|
||||
&client.Prenom,
|
||||
&client.Telephone,
|
||||
&client.Command,
|
||||
&client.Point,
|
||||
&client.PointZipette,
|
||||
&client.Amende,
|
||||
&client.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GetClientByUsername récupère un client par son username
|
||||
func (d *Database) GetClientByUsername(username string) (*models.Client, error) {
|
||||
client := &models.Client{}
|
||||
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, created_at
|
||||
FROM clients WHERE username = $1`
|
||||
|
||||
err := d.QueryRow(query, username).Scan(
|
||||
&client.ID,
|
||||
&client.Username,
|
||||
&client.Password,
|
||||
&client.Nom,
|
||||
&client.Prenom,
|
||||
&client.Telephone,
|
||||
&client.Command,
|
||||
&client.Point,
|
||||
&client.PointZipette,
|
||||
&client.Amende,
|
||||
&client.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération du client: %w", err)
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesInfo(username string) (map[string]interface{}, error) {
|
||||
// Récupérer le montant des pénalités
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Récupérer le nombre d'annulations
|
||||
cancellationsCount, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup annulations: %v", err)
|
||||
cancellationsCount = 0
|
||||
}
|
||||
|
||||
// Récupérer l'historique d'annulations
|
||||
cancellationHistory, err := d.GetClientCancellationHistory(username)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetClientPenaltiesInfo] Erreur récup historique: %v", err)
|
||||
cancellationHistory = map[string]interface{}{
|
||||
"cancellations_count": cancellationsCount,
|
||||
"next_penalty": 20,
|
||||
}
|
||||
}
|
||||
|
||||
info := map[string]interface{}{
|
||||
"username": username,
|
||||
"total_penalty": amende,
|
||||
"cancellations_count": cancellationsCount,
|
||||
"cancellation_history": cancellationHistory,
|
||||
"has_penalties": amende > 0,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// CheckClientCanOrder vérifie si un client peut passer commande (pas de pénalités impayées)
|
||||
func (d *Database) CheckClientCanOrder(username string) (bool, float64, error) {
|
||||
amende, err := d.GetClientAmende(username)
|
||||
if err != nil {
|
||||
return false, 0, err
|
||||
}
|
||||
|
||||
if amende > 0 {
|
||||
log.Printf("⚠️ [CheckClientCanOrder] Client %s bloqué: %.2f points de pénalités", username, amende)
|
||||
return false, amende, fmt.Errorf("pénalités impayées: %.2f points", amende)
|
||||
}
|
||||
|
||||
return true, 0, nil
|
||||
}
|
||||
|
||||
func (d *Database) ResetClientPenalties(username string, resetCancellationsCount bool) error {
|
||||
log.Printf("🔄 [ResetClientPenalties] Reset pour %s (reset_count=%v)", username, resetCancellationsCount)
|
||||
|
||||
var query string
|
||||
if resetCancellationsCount {
|
||||
query = `UPDATE clients
|
||||
SET amende = 0,
|
||||
cancellations_count = 0,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $1`
|
||||
} else {
|
||||
query = `UPDATE clients
|
||||
SET amende = 0,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $1`
|
||||
}
|
||||
|
||||
result, err := d.Exec(query, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ResetClientPenalties] Erreur UPDATE: %v", err)
|
||||
return fmt.Errorf("erreur reset pénalités: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ResetClientPenalties] Reset effectué pour %s", username)
|
||||
|
||||
// Invalider le cache Redis du client
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllClientsWithPenalties() ([]map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT username, amende, COALESCE(cancellations_count, 0) as cancellations_count, updated_at
|
||||
FROM clients
|
||||
WHERE amende > 0
|
||||
ORDER BY amende DESC
|
||||
`
|
||||
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllClientsWithPenalties] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération clients: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var clients []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var username string
|
||||
var amende float64
|
||||
var cancellationsCount int
|
||||
var updatedAt interface{}
|
||||
|
||||
err := rows.Scan(&username, &amende, &cancellationsCount, &updatedAt)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetAllClientsWithPenalties] Erreur scan: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
clients = append(clients, map[string]interface{}{
|
||||
"username": username,
|
||||
"total_penalty": amende,
|
||||
"cancellations_count": cancellationsCount,
|
||||
"last_updated": updatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetAllClientsWithPenalties] %d clients avec pénalités", len(clients))
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetClientPenaltiesStats() (map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(CASE WHEN amende > 0 THEN 1 END) as clients_with_penalties,
|
||||
COALESCE(SUM(amende), 0) as total_penalties,
|
||||
COALESCE(AVG(amende), 0) as avg_penalty,
|
||||
COALESCE(MAX(amende), 0) as max_penalty,
|
||||
COUNT(*) as total_clients
|
||||
FROM clients
|
||||
`
|
||||
|
||||
var stats struct {
|
||||
ClientsWithPenalties int
|
||||
TotalPenalties float64
|
||||
AvgPenalty float64
|
||||
MaxPenalty float64
|
||||
TotalClients int
|
||||
}
|
||||
|
||||
err := d.QueryRow(query).Scan(
|
||||
&stats.ClientsWithPenalties,
|
||||
&stats.TotalPenalties,
|
||||
&stats.AvgPenalty,
|
||||
&stats.MaxPenalty,
|
||||
&stats.TotalClients,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetClientPenaltiesStats] Erreur: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"clients_with_penalties": stats.ClientsWithPenalties,
|
||||
"total_penalties": stats.TotalPenalties,
|
||||
"average_penalty": stats.AvgPenalty,
|
||||
"max_penalty": stats.MaxPenalty,
|
||||
"total_clients": stats.TotalClients,
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetClientPenaltiesStats] Stats: %d/%d clients avec pénalités",
|
||||
stats.ClientsWithPenalties, stats.TotalClients)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ✅ FONCTION MODIFIÉE: Calculer les points sans cumuler entre catégories
|
||||
// À remplacer dans db/clients.go à partir de la ligne 498
|
||||
|
||||
// À remplacer dans db/clients.go
|
||||
|
||||
func (d *Database) CalculatePointsForCommand(commandID int) (int, string, error) {
|
||||
query := `
|
||||
SELECT p.category, ci.prix, ci.quantite
|
||||
FROM command_items ci
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = $1
|
||||
`
|
||||
|
||||
rows, err := d.Query(query, commandID)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
zipetteTotal := 0.0
|
||||
weedTotal := 0.0
|
||||
grosSemiTotal := 0.0
|
||||
|
||||
for rows.Next() {
|
||||
var category string
|
||||
var prix float64
|
||||
var quantite int
|
||||
|
||||
if err := rows.Scan(&category, &prix, &quantite); err != nil {
|
||||
log.Printf("⚠️ Erreur scan item: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
itemTotal := prix // Prix déjà calculé pour la quantité
|
||||
categoryLower := strings.ToLower(category)
|
||||
|
||||
if categoryLower == "zipette&co" || categoryLower == "zipette_co" {
|
||||
zipetteTotal += itemTotal
|
||||
} else if categoryLower == "gros&semi" || categoryLower == "gros_semi" {
|
||||
grosSemiTotal += itemTotal
|
||||
} else {
|
||||
weedTotal += itemTotal
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("💰 [CALC_POINTS] Cmd %d - Zipette: %.2f€, Weed: %.2f€, GrosSemi: %.2f€",
|
||||
commandID, zipetteTotal, weedTotal, grosSemiTotal)
|
||||
|
||||
// ✅ CALCUL POINTS WEED&HASH
|
||||
weedPoints := 0
|
||||
if weedTotal > 0 {
|
||||
switch {
|
||||
case weedTotal >= 30 && weedTotal <= 50:
|
||||
weedPoints = 1
|
||||
case weedTotal >= 60 && weedTotal <= 150:
|
||||
weedPoints = 2
|
||||
case weedTotal >= 160 && weedTotal <= 300:
|
||||
weedPoints = 3
|
||||
case weedTotal >= 310 && weedTotal <= 400:
|
||||
weedPoints = 5
|
||||
case weedTotal >= 400:
|
||||
weedPoints = 10
|
||||
}
|
||||
if weedPoints > 0 {
|
||||
log.Printf("🎁 [CALC_POINTS] Weed: %.2f€ → %d points", weedTotal, weedPoints)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ CALCUL POINTS ZIPETTE&CO
|
||||
zipettePoints := 0
|
||||
if zipetteTotal > 0 {
|
||||
switch {
|
||||
case zipetteTotal >= 30 && zipetteTotal <= 100:
|
||||
zipettePoints = 1
|
||||
case zipetteTotal >= 110 && zipetteTotal <= 200:
|
||||
zipettePoints = 2
|
||||
case zipetteTotal >= 210:
|
||||
zipettePoints = 3
|
||||
}
|
||||
if zipettePoints > 0 {
|
||||
log.Printf("🎁 [CALC_POINTS] Zipette: %.2f€ → %d points", zipetteTotal, zipettePoints)
|
||||
}
|
||||
}
|
||||
|
||||
totalPoints := zipettePoints + weedPoints
|
||||
|
||||
if totalPoints == 0 {
|
||||
if grosSemiTotal > 0 && zipetteTotal == 0 && weedTotal == 0 {
|
||||
log.Printf("ℹ️ [CALC_POINTS] Cmd %d - Catégorie GROS&SEMI uniquement (%.2f€) → 0 points",
|
||||
commandID, grosSemiTotal)
|
||||
return 0, "gros&semi", nil
|
||||
}
|
||||
log.Printf("ℹ️ [CALC_POINTS] Cmd %d - Aucun montant éligible aux points", commandID)
|
||||
return 0, "unknown", nil
|
||||
}
|
||||
|
||||
var dominantCategory string
|
||||
if zipetteTotal >= weedTotal {
|
||||
dominantCategory = "zipette&co"
|
||||
} else {
|
||||
dominantCategory = "weed&hash"
|
||||
}
|
||||
|
||||
log.Printf("✅ [CALC_POINTS] Cmd %d - Total: %d points (Zipette: %d, Weed: %d)",
|
||||
commandID, totalPoints, zipettePoints, weedPoints)
|
||||
|
||||
return totalPoints, dominantCategory, nil
|
||||
}
|
||||
|
||||
// ✅ FONCTION CORRIGÉE: Calculer et ajouter les points séparément avec le BON BARÈME
|
||||
func (d *Database) CalculateAndAddPointsForCommand(commandID int, username string) (int, error) {
|
||||
query := `
|
||||
SELECT p.category, ci.prix, ci.quantite
|
||||
FROM command_items ci
|
||||
JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = $1
|
||||
`
|
||||
|
||||
rows, err := d.Query(query, commandID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
zipetteTotal := 0.0
|
||||
weedTotal := 0.0
|
||||
grosSemiTotal := 0.0
|
||||
|
||||
for rows.Next() {
|
||||
var category string
|
||||
var prix float64
|
||||
var quantite int
|
||||
|
||||
if err := rows.Scan(&category, &prix, &quantite); err != nil {
|
||||
log.Printf("⚠️ Erreur scan item: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ prix contient déjà le total pour la quantité
|
||||
itemTotal := prix
|
||||
categoryLower := strings.ToLower(category)
|
||||
|
||||
if categoryLower == "zipette&co" || categoryLower == "zipette_co" {
|
||||
zipetteTotal += itemTotal
|
||||
} else if categoryLower == "gros&semi" || categoryLower == "gros_semi" {
|
||||
grosSemiTotal += itemTotal
|
||||
} else {
|
||||
weedTotal += itemTotal
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("💰 [CALC_POINTS] Cmd %d - Zipette: %.2f€, Weed: %.2f€, GrosSemi: %.2f€",
|
||||
commandID, zipetteTotal, weedTotal, grosSemiTotal)
|
||||
|
||||
// ✅ CALCUL POINTS WEED&HASH - NOUVEAU BARÈME
|
||||
// De 30 à 50€ -> 1 point
|
||||
// De 60 à 150€ -> 2 points
|
||||
// De 160 à 300€ -> 3 points
|
||||
// De 310 à 400€ -> 5 points
|
||||
// 400€ et + -> 10 points
|
||||
weedPoints := 0
|
||||
if weedTotal > 0 {
|
||||
switch {
|
||||
case weedTotal >= 30 && weedTotal <= 50:
|
||||
weedPoints = 1
|
||||
case weedTotal >= 60 && weedTotal <= 150:
|
||||
weedPoints = 2
|
||||
case weedTotal >= 160 && weedTotal <= 300:
|
||||
weedPoints = 3
|
||||
case weedTotal >= 310 && weedTotal <= 400:
|
||||
weedPoints = 5
|
||||
case weedTotal >= 400:
|
||||
weedPoints = 10
|
||||
}
|
||||
if weedPoints > 0 {
|
||||
log.Printf("🎁 [CALC_POINTS] Weed: %.2f€ → %d points", weedTotal, weedPoints)
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ CALCUL POINTS ZIPETTE&CO - NOUVEAU BARÈME
|
||||
// De 30 à 100€ -> 1 point
|
||||
// De 110 à 200€ -> 2 points (je suppose que c'est 110 et non 1100)
|
||||
// De 210€ et + -> 3 points
|
||||
zipettePoints := 0
|
||||
if zipetteTotal > 0 {
|
||||
switch {
|
||||
case zipetteTotal >= 30 && zipetteTotal <= 100:
|
||||
zipettePoints = 1
|
||||
case zipetteTotal >= 110 && zipetteTotal <= 200:
|
||||
zipettePoints = 2
|
||||
case zipetteTotal >= 210:
|
||||
zipettePoints = 3
|
||||
}
|
||||
if zipettePoints > 0 {
|
||||
log.Printf("🎁 [CALC_POINTS] Zipette: %.2f€ → %d points", zipetteTotal, zipettePoints)
|
||||
}
|
||||
}
|
||||
|
||||
totalPoints := 0
|
||||
|
||||
// ✅ AJOUTER LES POINTS SÉPARÉMENT PAR CATÉGORIE
|
||||
if zipettePoints > 0 {
|
||||
if err := d.AddClientPointsByCategory(username, zipettePoints, "zipette&co"); err != nil {
|
||||
log.Printf("❌ Erreur ajout points Zipette: %v", err)
|
||||
} else {
|
||||
totalPoints += zipettePoints
|
||||
log.Printf("✅ %d points ZIPETTE ajoutés à %s", zipettePoints, username)
|
||||
}
|
||||
}
|
||||
|
||||
if weedPoints > 0 {
|
||||
if err := d.AddClientPointsByCategory(username, weedPoints, "weed&hash"); err != nil {
|
||||
log.Printf("❌ Erreur ajout points Weed: %v", err)
|
||||
} else {
|
||||
totalPoints += weedPoints
|
||||
log.Printf("✅ %d points WEED ajoutés à %s", weedPoints, username)
|
||||
}
|
||||
}
|
||||
|
||||
if totalPoints == 0 {
|
||||
if grosSemiTotal > 0 && zipetteTotal == 0 && weedTotal == 0 {
|
||||
log.Printf("ℹ️ [CALC_POINTS] Cmd %d - Catégorie GROS&SEMI uniquement → 0 points", commandID)
|
||||
} else {
|
||||
log.Printf("ℹ️ [CALC_POINTS] Cmd %d - Aucun point éligible (Weed: %.2f€, Zipette: %.2f€)",
|
||||
commandID, weedTotal, zipetteTotal)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CALC_POINTS] Cmd %d - Total: %d points (Zipette: %d, Weed: %d)",
|
||||
commandID, totalPoints, zipettePoints, weedPoints)
|
||||
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, username string) (int, error) {
|
||||
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
|
||||
|
||||
// ✅ ÉTAPE 1: Récupérer tous les items de la commande avec leurs catégories
|
||||
query := `
|
||||
SELECT ci.quantite, ci.prix, COALESCE(p.category, 'weed_hash') as category
|
||||
FROM command_items ci
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = $1
|
||||
`
|
||||
|
||||
rows, err := tx.Query(query, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
|
||||
return 0, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type ItemPoints struct {
|
||||
Category string
|
||||
Quantite int
|
||||
Prix float64
|
||||
}
|
||||
|
||||
var items []ItemPoints
|
||||
totalPrixWeedHash := 0.0
|
||||
totalPrixZipette := 0.0
|
||||
|
||||
for rows.Next() {
|
||||
var item ItemPoints
|
||||
if err := rows.Scan(&item.Quantite, &item.Prix, &item.Category); err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur scan: %v", err)
|
||||
return 0, fmt.Errorf("erreur lecture item: %w", err)
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
|
||||
// Cumuler par catégorie
|
||||
if item.Category == "zipette" {
|
||||
totalPrixZipette += item.Prix
|
||||
} else {
|
||||
// weed_hash ou autres catégories
|
||||
totalPrixWeedHash += item.Prix
|
||||
}
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur rows: %v", err)
|
||||
return 0, fmt.Errorf("erreur itération items: %w", err)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
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)
|
||||
totalPoints := pointsWeedHash + pointsZipette
|
||||
|
||||
log.Printf("💰 [CalcPointsTx] Points calculés - weed_hash: %d, zipette: %d, total: %d",
|
||||
pointsWeedHash, pointsZipette, totalPoints)
|
||||
|
||||
// ✅ ÉTAPE 3: Mettre à jour les points du client (dans la transaction)
|
||||
if pointsWeedHash > 0 || pointsZipette > 0 {
|
||||
updateQuery := `
|
||||
UPDATE clients
|
||||
SET
|
||||
point = point + $1,
|
||||
point_zipette = point_zipette + $2,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $3
|
||||
`
|
||||
|
||||
result, err := tx.Exec(updateQuery, pointsWeedHash, pointsZipette, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points: %v", err)
|
||||
return 0, fmt.Errorf("erreur mise à jour points: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
log.Printf("⚠️ [CalcPointsTx] Client %s non trouvé", username)
|
||||
return 0, fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [CalcPointsTx] Points ajoutés: +%d weed_hash, +%d zipette pour %s",
|
||||
pointsWeedHash, pointsZipette, username)
|
||||
}
|
||||
|
||||
log.Printf("🎉 [CalcPointsTx] SUCCÈS - Total %d points attribués", totalPoints)
|
||||
|
||||
return totalPoints, nil
|
||||
}
|
||||
|
||||
func (d *Database) CanUserAccessCommand(
|
||||
commandID int,
|
||||
username string,
|
||||
role string,
|
||||
) (bool, error) {
|
||||
|
||||
// 👑 Admin : accès total
|
||||
if role == "admin" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var exists bool
|
||||
|
||||
// 🚚 Livreur : seulement commandes assignées
|
||||
if role == "livreur" {
|
||||
err := d.QueryRow(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM commandes
|
||||
WHERE id = $1 AND livreur_assign = $2
|
||||
)
|
||||
`, commandID, username).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// 👤 User : seulement SES commandes
|
||||
err := d.QueryRow(`
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM commandes
|
||||
WHERE id = $1 AND username = $2
|
||||
)
|
||||
`, commandID, username).Scan(&exists)
|
||||
|
||||
return exists, err
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// VALIDATION HELPERS
|
||||
// ============================================
|
||||
|
||||
func validateCommandID(commandID int) error {
|
||||
if commandID <= 0 {
|
||||
return fmt.Errorf("ID commande invalide: %d", commandID)
|
||||
}
|
||||
if commandID > 2147483647 {
|
||||
return fmt.Errorf("ID commande trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateItemID(itemID int) error {
|
||||
if itemID <= 0 {
|
||||
return fmt.Errorf("ID item invalide: %d", itemID)
|
||||
}
|
||||
if itemID > 2147483647 {
|
||||
return fmt.Errorf("ID item trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateQuantite(quantite int) error {
|
||||
if quantite <= 0 {
|
||||
return fmt.Errorf("quantité doit être > 0")
|
||||
}
|
||||
if quantite > 10000 {
|
||||
return fmt.Errorf("quantité trop élevée (max 10000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePrix(prix float64) error {
|
||||
if prix <= 0 {
|
||||
return fmt.Errorf("prix doit être > 0")
|
||||
}
|
||||
if prix > 100000 {
|
||||
return fmt.Errorf("prix trop élevé (max 100000)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUsername(username string) error {
|
||||
if len(username) == 0 {
|
||||
return fmt.Errorf("username vide")
|
||||
}
|
||||
if len(username) > 100 {
|
||||
return fmt.Errorf("username trop long (max 100)")
|
||||
}
|
||||
// Sanitize
|
||||
if strings.Contains(username, "..") || strings.Contains(username, "/") {
|
||||
return fmt.Errorf("username invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDeliveryAddress(address string) error {
|
||||
if len(address) == 0 {
|
||||
return fmt.Errorf("adresse vide")
|
||||
}
|
||||
if len(address) > 500 {
|
||||
return fmt.Errorf("adresse trop longue (max 500)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateItemStatus(status string) error {
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled"}
|
||||
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
|
||||
for _, valid := range validStatuses {
|
||||
if status == valid {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INSERT COMMAND ITEM - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (d *Database) InsertCommandItemWithClientInfo(
|
||||
commandID int,
|
||||
produit string,
|
||||
productID, quantite int,
|
||||
prix float64,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress string,
|
||||
) error {
|
||||
log.Printf("📝 [InsertCommandItemWithClientInfo] START - commandID=%d, produit=%s", commandID, produit)
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateProductID(productID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateQuantite(quantite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validatePrix(prix); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateUsername(clientUsername); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateDeliveryAddress(deliveryAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ SANITIZE STRINGS
|
||||
produit = strings.TrimSpace(produit)
|
||||
if len(produit) > 200 {
|
||||
produit = produit[:200]
|
||||
}
|
||||
|
||||
clientNom = strings.TrimSpace(clientNom)
|
||||
if len(clientNom) > 100 {
|
||||
clientNom = clientNom[:100]
|
||||
}
|
||||
|
||||
clientPrenom = strings.TrimSpace(clientPrenom)
|
||||
if len(clientPrenom) > 100 {
|
||||
clientPrenom = clientPrenom[:100]
|
||||
}
|
||||
|
||||
clientTelephone = strings.TrimSpace(clientTelephone)
|
||||
if len(clientTelephone) > 20 {
|
||||
clientTelephone = clientTelephone[:20]
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LA COMMANDE EXISTE
|
||||
var exists bool
|
||||
checkQuery := `SELECT EXISTS(SELECT 1 FROM commandes WHERE id = $1)`
|
||||
err := d.QueryRow(checkQuery, commandID).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification commande: %v", err)
|
||||
return fmt.Errorf("erreur vérification commande: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("commande %d n'existe pas", commandID)
|
||||
}
|
||||
|
||||
// ✅ INSERT
|
||||
query := `INSERT INTO command_items (
|
||||
command_id, produit, product_id, quantite, prix,
|
||||
client_username, client_nom, client_prenom, client_telephone, delivery_address,
|
||||
status, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
|
||||
|
||||
_, err = d.Exec(query,
|
||||
commandID, produit, productID, quantite, prix,
|
||||
clientUsername, clientNom, clientPrenom, clientTelephone, deliveryAddress,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur INSERT command_items: %v", err)
|
||||
return fmt.Errorf("erreur insertion item: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Item inséré avec infos client: %s %s", clientNom, clientPrenom)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET COMMAND ITEMS - VERSION SÉCURISÉE + FIX NULL
|
||||
// ============================================
|
||||
|
||||
func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, error) {
|
||||
log.Printf("📦 [GetCommandItems] START - commandID=%d", commandID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateCommandID(commandID); err != nil {
|
||||
log.Printf("❌ [GetCommandItems] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
ci.id,
|
||||
ci.command_id,
|
||||
ci.produit,
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
ci.client_username,
|
||||
ci.client_nom,
|
||||
ci.client_prenom,
|
||||
ci.client_telephone,
|
||||
ci.delivery_address,
|
||||
ci.status,
|
||||
ci.created_at,
|
||||
ci.updated_at,
|
||||
c.status as command_status,
|
||||
c.adresse as command_address,
|
||||
c.total_prix,
|
||||
c.livreur_assign,
|
||||
c.created_at as command_created_at,
|
||||
COALESCE(p.category, 'weed_hash') as category
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
LEFT JOIN products p ON ci.product_id = p.id
|
||||
WHERE ci.command_id = $1
|
||||
ORDER BY ci.id ASC`
|
||||
|
||||
rows, err := d.Query(query, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []map[string]interface{}
|
||||
|
||||
for rows.Next() {
|
||||
var id, commandID, quantite int
|
||||
var productID sql.NullInt64 // ✅ FIX: Utiliser NullInt64 pour gérer NULL
|
||||
var produit, clientUsername, clientNom, clientPrenom, clientTelephone string
|
||||
var deliveryAddress, status sql.NullString
|
||||
var prix, totalPrix float64
|
||||
var createdAt, updatedAt, commandCreatedAt time.Time
|
||||
var commandStatus, commandAddress, livreurAssign sql.NullString
|
||||
var category string
|
||||
|
||||
// ✅ FIX: Utiliser &productID (sql.NullInt64)
|
||||
err := rows.Scan(
|
||||
&id, &commandID, &produit, &productID, &quantite, &prix,
|
||||
&clientUsername, &clientNom, &clientPrenom, &clientTelephone, &deliveryAddress, &status,
|
||||
&createdAt, &updatedAt,
|
||||
&commandStatus, &commandAddress, &totalPrix, &livreurAssign, &commandCreatedAt,
|
||||
&category,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur scan: %v", err)
|
||||
return nil, fmt.Errorf("erreur scan: %w", err)
|
||||
}
|
||||
|
||||
// ✅ CONVERTIR sql.NullInt64 en int (0 si NULL)
|
||||
productIDValue := 0
|
||||
if productID.Valid {
|
||||
productIDValue = int(productID.Int64)
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
"id": id,
|
||||
"command_id": commandID,
|
||||
"produit": produit,
|
||||
"product_id": productIDValue, // ✅ FIX: Utiliser la valeur convertie
|
||||
"quantite": quantite,
|
||||
"prix": prix,
|
||||
"client_username": clientUsername,
|
||||
"client_nom": clientNom,
|
||||
"client_prenom": clientPrenom,
|
||||
"client_telephone": clientTelephone,
|
||||
"delivery_address": deliveryAddress.String,
|
||||
"status": status.String,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
// Infos commande
|
||||
"command_status": commandStatus.String,
|
||||
"command_address": commandAddress.String,
|
||||
"total_prix": totalPrix,
|
||||
"livreur_assign": livreurAssign.String,
|
||||
"command_created_at": commandCreatedAt,
|
||||
"category": category,
|
||||
}
|
||||
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Printf("❌ Erreur itération: %v", err)
|
||||
return nil, fmt.Errorf("erreur itération: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ %d items récupérés avec infos client et catégories", len(items))
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET COMMAND ITEMS BY USERNAME - VERSION SÉCURISÉE + FIX NULL
|
||||
// ============================================
|
||||
|
||||
func (d *Database) GetCommandItemsByUsername(username string) ([]map[string]interface{}, error) {
|
||||
log.Printf("📦 [GetCommandItemsByUsername] START - username=%s", username)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateUsername(username); err != nil {
|
||||
log.Printf("❌ [GetCommandItemsByUsername] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
ci.id,
|
||||
ci.command_id,
|
||||
ci.produit,
|
||||
ci.product_id,
|
||||
ci.quantite,
|
||||
ci.prix,
|
||||
ci.client_username,
|
||||
ci.client_nom,
|
||||
ci.client_prenom,
|
||||
ci.client_telephone,
|
||||
ci.delivery_address,
|
||||
ci.status,
|
||||
ci.created_at,
|
||||
ci.updated_at,
|
||||
c.status as command_status,
|
||||
c.adresse as command_address,
|
||||
c.total_prix,
|
||||
c.livreur_assign,
|
||||
c.created_at as command_created_at
|
||||
FROM command_items ci
|
||||
LEFT JOIN commandes c ON ci.command_id = c.id
|
||||
WHERE ci.client_username = $1
|
||||
ORDER BY ci.command_id DESC, ci.id ASC`
|
||||
|
||||
rows, err := d.Query(query, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération items: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var items []map[string]interface{}
|
||||
|
||||
for rows.Next() {
|
||||
var id, commandID, quantite int
|
||||
var productID sql.NullInt64 // ✅ FIX: NullInt64
|
||||
var produit, clientUsername, clientNom, clientPrenom, clientTelephone string
|
||||
var deliveryAddress, status sql.NullString
|
||||
var prix, totalPrix float64
|
||||
var createdAt, updatedAt, commandCreatedAt time.Time
|
||||
var commandStatus, commandAddress, livreurAssign sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&id, &commandID, &produit, &productID, &quantite, &prix,
|
||||
&clientUsername, &clientNom, &clientPrenom, &clientTelephone, &deliveryAddress, &status,
|
||||
&createdAt, &updatedAt,
|
||||
&commandStatus, &commandAddress, &totalPrix, &livreurAssign, &commandCreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur scan: %v", err)
|
||||
return nil, fmt.Errorf("erreur scan: %w", err)
|
||||
}
|
||||
|
||||
// ✅ CONVERTIR NullInt64
|
||||
productIDValue := 0
|
||||
if productID.Valid {
|
||||
productIDValue = int(productID.Int64)
|
||||
}
|
||||
|
||||
item := map[string]interface{}{
|
||||
"id": id,
|
||||
"command_id": commandID,
|
||||
"produit": produit,
|
||||
"product_id": productIDValue, // ✅ FIX
|
||||
"quantite": quantite,
|
||||
"prix": prix,
|
||||
"client_username": clientUsername,
|
||||
"client_nom": clientNom,
|
||||
"client_prenom": clientPrenom,
|
||||
"client_telephone": clientTelephone,
|
||||
"delivery_address": deliveryAddress.String,
|
||||
"status": status.String,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
// Infos commande
|
||||
"command_status": commandStatus.String,
|
||||
"command_address": commandAddress.String,
|
||||
"total_prix": totalPrix,
|
||||
"livreur_assign": livreurAssign.String,
|
||||
"command_created_at": commandCreatedAt,
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Printf("❌ Erreur itération: %v", err)
|
||||
return nil, fmt.Errorf("erreur itération: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ %d items récupérés pour l'utilisateur %s", len(items), username)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UPDATE COMMAND ITEM STATUS - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (d *Database) UpdateCommandItemStatus(itemID int, status string) error {
|
||||
log.Printf("📝 [UpdateCommandItemStatus] START - itemID=%d, status=%s", itemID, status)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateItemID(itemID); err != nil {
|
||||
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateItemStatus(status); err != nil {
|
||||
log.Printf("❌ [UpdateCommandItemStatus] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE L'ITEM EXISTE
|
||||
var exists bool
|
||||
checkQuery := `SELECT EXISTS(SELECT 1 FROM command_items WHERE id = $1)`
|
||||
err := d.QueryRow(checkQuery, itemID).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification item: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ Item %d non trouvé", itemID)
|
||||
return fmt.Errorf("item %d non trouvé", itemID)
|
||||
}
|
||||
|
||||
// ✅ UPDATE
|
||||
query := `UPDATE command_items
|
||||
SET status = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`
|
||||
|
||||
result, err := d.Exec(query, status, itemID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur UPDATE: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur RowsAffected: %v", err)
|
||||
return fmt.Errorf("erreur vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
log.Printf("❌ Item %d non trouvé", itemID)
|
||||
return fmt.Errorf("item non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Statut item %d mis à jour: %s", itemID, status)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
// ============================================
|
||||
// db/commands_priority.go
|
||||
// 🎯 SYSTÈME DE PRIORISATION DES COMMANDES
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetAllCommandsOldestFirst récupère les commandes triées par ancienneté (plus anciennes en premier)
|
||||
// Utilisé pour le système de priorisation automatique
|
||||
func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[string]interface{}, error) {
|
||||
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at
|
||||
FROM commandes c
|
||||
WHERE 1=1`
|
||||
|
||||
args := []interface{}{}
|
||||
argPosition := 1
|
||||
|
||||
// Filtrer par status
|
||||
if status != "" {
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled", "support"}
|
||||
isValid := false
|
||||
for _, vs := range validStatuses {
|
||||
if status == vs {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !isValid {
|
||||
return nil, fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
query += fmt.Sprintf(" AND c.status = $%d", argPosition)
|
||||
args = append(args, status)
|
||||
argPosition++
|
||||
}
|
||||
|
||||
// Filtrer par username si fourni
|
||||
if username != "" {
|
||||
query += fmt.Sprintf(" AND c.username = $%d", argPosition)
|
||||
args = append(args, username)
|
||||
argPosition++
|
||||
}
|
||||
|
||||
// ✅ TRI PAR ANCIENNETÉ: Les plus anciennes d'abord (ASC)
|
||||
query += " ORDER BY c.created_at ASC"
|
||||
|
||||
log.Printf("🔍 [PRIORITY] Query: %s | Args: %v", query, args)
|
||||
|
||||
rows, err := d.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commandes prioritaires: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var commands []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username, status, adresse string
|
||||
var livreurAssign sql.NullString
|
||||
var totalPrix float64
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &livreurAssign, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur scan commande: %w", err)
|
||||
}
|
||||
|
||||
command := map[string]interface{}{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"status": status,
|
||||
"adresse": adresse,
|
||||
"total_prix": totalPrix,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}
|
||||
|
||||
if livreurAssign.Valid {
|
||||
command["livreur_assign"] = livreurAssign.String
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("erreur itération résultats: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [PRIORITY] %d commandes récupérées (ordre: plus anciennes → plus récentes)", len(commands))
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetOldestPendingCommand récupère la commande pending la plus ancienne
|
||||
func (d *Database) GetOldestPendingCommand() (map[string]interface{}, error) {
|
||||
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.livreur_assign, c.created_at, c.updated_at
|
||||
FROM commandes c
|
||||
WHERE c.status = 'pending'
|
||||
ORDER BY c.created_at ASC
|
||||
LIMIT 1`
|
||||
|
||||
var id int
|
||||
var username, status, adresse string
|
||||
var livreurAssign sql.NullString
|
||||
var totalPrix float64
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := d.QueryRow(query).Scan(
|
||||
&id, &username, &status, &adresse, &totalPrix,
|
||||
&livreurAssign, &createdAt, &updatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil // Aucune commande pending
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commande la plus ancienne: %w", err)
|
||||
}
|
||||
|
||||
command := map[string]interface{}{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"status": status,
|
||||
"adresse": adresse,
|
||||
"total_prix": totalPrix,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}
|
||||
|
||||
if livreurAssign.Valid {
|
||||
command["livreur_assign"] = livreurAssign.String
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
log.Printf("📌 [PRIORITY] Commande la plus ancienne: ID=%d, créée le %s",
|
||||
id, createdAt.Format("2006-01-02 15:04:05"))
|
||||
|
||||
return command, nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsWithPriority récupère les commandes pending avec calcul de priorité
|
||||
func (d *Database) GetPendingCommandsWithPriority() ([]*models.CommandPriority, error) {
|
||||
query := `SELECT c.id, c.username, c.status, c.adresse, c.total_prix,
|
||||
c.created_at, c.updated_at,
|
||||
EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - c.created_at)) as waiting_seconds
|
||||
FROM commandes c
|
||||
WHERE c.status = 'pending'
|
||||
ORDER BY c.created_at ASC`
|
||||
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération commandes avec priorité: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var commands []*models.CommandPriority
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username, status, adresse string
|
||||
var totalPrix float64
|
||||
var createdAt, updatedAt time.Time
|
||||
var waitingSeconds float64
|
||||
|
||||
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix,
|
||||
&createdAt, &updatedAt, &waitingSeconds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur scan commande priorité: %w", err)
|
||||
}
|
||||
|
||||
cmd := &models.CommandPriority{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Status: status,
|
||||
Address: adresse,
|
||||
TotalPrice: totalPrix,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
WaitingSeconds: int(waitingSeconds),
|
||||
WaitingMinutes: int(waitingSeconds / 60),
|
||||
}
|
||||
|
||||
commands = append(commands, cmd)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("erreur itération résultats priorité: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [PRIORITY] %d commandes avec score de priorité calculé", len(commands))
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetCommandWaitingTime récupère le temps d'attente d'une commande
|
||||
func (d *Database) GetCommandWaitingTime(commandID int) (int, error) {
|
||||
query := `SELECT EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))::INTEGER as waiting_seconds
|
||||
FROM commandes
|
||||
WHERE id = $1`
|
||||
|
||||
var waitingSeconds int
|
||||
err := d.QueryRow(query, commandID).Scan(&waitingSeconds)
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération temps d'attente: %w", err)
|
||||
}
|
||||
|
||||
return waitingSeconds, nil
|
||||
}
|
||||
|
||||
// GetPendingCommandsStats récupère des statistiques sur les commandes en attente
|
||||
func (d *Database) GetPendingCommandsStats() (map[string]interface{}, error) {
|
||||
query := `SELECT
|
||||
COUNT(*) as total_pending,
|
||||
AVG(EXTRACT(EPOCH FROM (CURRENT_TIMESTAMP - created_at))) as avg_waiting_seconds,
|
||||
MIN(created_at) as oldest_command_date,
|
||||
MAX(created_at) as newest_command_date
|
||||
FROM commandes
|
||||
WHERE status = 'pending'`
|
||||
|
||||
var totalPending int
|
||||
var avgWaitingSeconds sql.NullFloat64
|
||||
var oldestDate, newestDate sql.NullTime
|
||||
|
||||
err := d.QueryRow(query).Scan(&totalPending, &avgWaitingSeconds, &oldestDate, &newestDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération stats: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total_pending": totalPending,
|
||||
"avg_waiting_seconds": 0,
|
||||
"avg_waiting_minutes": 0,
|
||||
"oldest_command_date": nil,
|
||||
"newest_command_date": nil,
|
||||
"oldest_waiting_minutes": 0,
|
||||
}
|
||||
|
||||
if avgWaitingSeconds.Valid {
|
||||
stats["avg_waiting_seconds"] = int(avgWaitingSeconds.Float64)
|
||||
stats["avg_waiting_minutes"] = int(avgWaitingSeconds.Float64 / 60)
|
||||
}
|
||||
|
||||
if oldestDate.Valid {
|
||||
stats["oldest_command_date"] = oldestDate.Time
|
||||
waitingTime := time.Since(oldestDate.Time)
|
||||
stats["oldest_waiting_minutes"] = int(waitingTime.Minutes())
|
||||
}
|
||||
|
||||
if newestDate.Valid {
|
||||
stats["newest_command_date"] = newestDate.Time
|
||||
}
|
||||
|
||||
log.Printf("📊 [STATS] Commandes pending: %d | Attente moyenne: %d min | Plus ancienne: %d min",
|
||||
totalPending,
|
||||
stats["avg_waiting_minutes"],
|
||||
stats["oldest_waiting_minutes"])
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,383 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetAvailableDeliveryPersons récupère tous les livreurs disponibles
|
||||
func (d *Database) GetAvailableDeliveryPersons() ([]map[string]interface{}, error) {
|
||||
query := `SELECT id, username, total, livraison
|
||||
FROM users
|
||||
WHERE role = 'livreur'
|
||||
ORDER BY username`
|
||||
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var livreurs []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username string
|
||||
var total, livraison float64
|
||||
|
||||
err := rows.Scan(&id, &username, &total, &livraison)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan du livreur: %w", err)
|
||||
}
|
||||
|
||||
livreur := map[string]interface{}{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"total": total,
|
||||
"livraison": livraison,
|
||||
}
|
||||
livreurs = append(livreurs, livreur)
|
||||
}
|
||||
|
||||
return livreurs, nil
|
||||
}
|
||||
|
||||
// Admin appelle cette fonction pour assigner un livreur
|
||||
func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) error {
|
||||
log.Printf("📦 [AssignDeliveryPerson] START - commandID=%d, livreur=%s", commandID, livreurUsername)
|
||||
|
||||
// ✅ DÉMARRER UNE TRANSACTION
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur démarrage transaction: %v", err)
|
||||
return fmt.Errorf("erreur démarrage transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback() // Rollback automatique si non commité
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 1: Vérifier le livreur (DANS la transaction)
|
||||
// ============================================
|
||||
var role string
|
||||
checkQuery := `SELECT role FROM users WHERE username = $1 FOR UPDATE`
|
||||
err = tx.QueryRow(checkQuery, livreurUsername).Scan(&role)
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("❌ Livreur '%s' non trouvé", livreurUsername)
|
||||
return fmt.Errorf("livreur non trouvé")
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification livreur: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification du livreur: %w", err)
|
||||
}
|
||||
if role != "livreur" {
|
||||
log.Printf("❌ L'utilisateur '%s' n'est pas un livreur (role=%s)", livreurUsername, role)
|
||||
return fmt.Errorf("l'utilisateur n'est pas un livreur")
|
||||
}
|
||||
|
||||
log.Printf(" ✅ Livreur valide: %s", livreurUsername)
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 2: Vérifier et VERROUILLER la commande
|
||||
// ✅ FOR UPDATE empêche les modifications concurrentes
|
||||
// ============================================
|
||||
var currentStatus string
|
||||
var currentLivreur sql.NullString
|
||||
statusQuery := `SELECT status, livreur_assign
|
||||
FROM commandes
|
||||
WHERE id = $1
|
||||
FOR UPDATE` // ⚠️ VERROUILLAGE CRITIQUE
|
||||
|
||||
err = tx.QueryRow(statusQuery, commandID).Scan(¤tStatus, ¤tLivreur)
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("❌ Commande %d non trouvée", commandID)
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur vérification commande: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
|
||||
}
|
||||
|
||||
log.Printf(" ✅ Commande trouvée: status=%s, livreur_assign=%s",
|
||||
currentStatus, currentLivreur.String)
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 3: Vérifier que la commande est assignable
|
||||
// ============================================
|
||||
|
||||
// ✅ Vérifier si déjà assignée à un autre livreur
|
||||
if currentLivreur.Valid && currentLivreur.String != "" && currentLivreur.String != livreurUsername {
|
||||
log.Printf("❌ Commande déjà assignée à: %s", currentLivreur.String)
|
||||
return fmt.Errorf("commande déjà assignée au livreur '%s'", currentLivreur.String)
|
||||
}
|
||||
|
||||
// ✅ Vérifier le statut
|
||||
validStatusesForAssignment := []string{"pending", "support"}
|
||||
isValidStatus := false
|
||||
for _, vs := range validStatusesForAssignment {
|
||||
if currentStatus == vs {
|
||||
isValidStatus = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !isValidStatus {
|
||||
log.Printf("❌ Statut invalide pour assignation: %s", currentStatus)
|
||||
return fmt.Errorf("commande en statut '%s', impossible d'assigner un livreur", currentStatus)
|
||||
}
|
||||
|
||||
log.Printf(" ✅ Statut valide pour assignation: %s", currentStatus)
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 4: Assigner le livreur (ATOMIQUE)
|
||||
// ============================================
|
||||
updateQuery := `UPDATE commandes
|
||||
SET livreur_assign = $1,
|
||||
status = 'support',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
AND status IN ('pending', 'support')
|
||||
AND (livreur_assign IS NULL OR livreur_assign = '' OR livreur_assign = $1)`
|
||||
|
||||
result, err := tx.Exec(updateQuery, livreurUsername, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur UPDATE: %v", err)
|
||||
return fmt.Errorf("erreur lors de l'assignation du livreur: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur RowsAffected: %v", err)
|
||||
return fmt.Errorf("erreur lors de la vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
log.Printf("❌ Impossible d'assigner: conditions non remplies")
|
||||
return fmt.Errorf("impossible d'assigner la commande (déjà assignée ou statut changé)")
|
||||
}
|
||||
|
||||
log.Printf(" ✅ Commande assignée au livreur: %s", livreurUsername)
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 5: Ajouter un log (DANS la transaction)
|
||||
// ============================================
|
||||
logQuery := `INSERT INTO command_logs (command_id, status, message, actor, created_at)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
|
||||
|
||||
_, err = tx.Exec(logQuery, commandID, "support",
|
||||
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
|
||||
"admin")
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur ajout log: %v", err)
|
||||
// Non bloquant
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ COMMIT de la transaction
|
||||
// ============================================
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur COMMIT: %v", err)
|
||||
return fmt.Errorf("erreur commit transaction: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("🎉 [AssignDeliveryPerson] SUCCÈS - Commande %d assignée à %s", commandID, livreurUsername)
|
||||
log.Printf(" Workflow: pending → ✅ support (TRANSACTION COMMITTED)")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonCommands récupère les commandes assignées à un livreur
|
||||
func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]interface{}, error) {
|
||||
query := `SELECT id, username, status, adresse, total_prix, livreur_assign, created_at, updated_at
|
||||
FROM commandes
|
||||
WHERE livreur_assign = $1`
|
||||
|
||||
args := []interface{}{livreurUsername}
|
||||
|
||||
if status != "" {
|
||||
query += " AND status = $2"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
rows, err := d.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var commands []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username, status, adresse string
|
||||
var livreurAssign sql.NullString
|
||||
var totalPrix float64
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := rows.Scan(&id, &username, &status, &adresse, &totalPrix, &livreurAssign, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan: %w", err)
|
||||
}
|
||||
|
||||
command := map[string]interface{}{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"status": status,
|
||||
"adresse": adresse,
|
||||
"total_prix": totalPrix,
|
||||
"livreur_assign": livreurAssign.String,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// ✅ NOUVELLE MÉTHODE: IncrementLivreurDeliveryCount incrémente le compteur de livraisons d'un livreur
|
||||
func (d *Database) IncrementLivreurDeliveryCount(livreurUsername string) error {
|
||||
query := `UPDATE users
|
||||
SET livraison = livraison + 1,
|
||||
total = total + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $1 AND role = 'livreur'`
|
||||
|
||||
result, err := d.Exec(query, livreurUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de l'incrémentation des livraisons: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("livreur non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Livraison incrémentée pour le livreur: %s", livreurUsername)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
|
||||
log.Printf("📝 [ApproveDelivery] START - commandID=%d, client=%s", commandID, clientUsername)
|
||||
|
||||
// ✅ DÉMARRER UNE TRANSACTION
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur démarrage transaction: %v", err)
|
||||
return fmt.Errorf("erreur démarrage transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 1: Vérifier et VERROUILLER la commande
|
||||
// ============================================
|
||||
var commandUsername, currentStatus string
|
||||
var livreurAssign sql.NullString
|
||||
|
||||
checkQuery := `SELECT username, status, livreur_assign
|
||||
FROM commandes
|
||||
WHERE id = $1
|
||||
FOR UPDATE` // ⚠️ VERROUILLAGE CRITIQUE
|
||||
|
||||
err = tx.QueryRow(checkQuery, commandID).Scan(&commandUsername, ¤tStatus, &livreurAssign)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification de la commande: %w", err)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 2: Validations métier
|
||||
// ============================================
|
||||
|
||||
// ✅ Vérifier que c'est bien la commande du client
|
||||
if commandUsername != clientUsername {
|
||||
return fmt.Errorf("cette commande ne vous appartient pas")
|
||||
}
|
||||
|
||||
// ✅ Vérifier le statut
|
||||
if currentStatus != "livre" {
|
||||
return fmt.Errorf("cette commande n'est pas encore livrée (statut actuel: %s)", currentStatus)
|
||||
}
|
||||
|
||||
log.Printf(" ✅ Validations OK: client=%s, status=%s", commandUsername, currentStatus)
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 3: Mettre à jour le statut (ATOMIQUE)
|
||||
// ============================================
|
||||
updateQuery := `UPDATE commandes
|
||||
SET status = 'approved',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
AND status = 'livre'
|
||||
AND username = $2`
|
||||
|
||||
result, err := tx.Exec(updateQuery, commandID, clientUsername)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de l'approbation de la livraison: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("impossible d'approuver: statut changé ou commande introuvable")
|
||||
}
|
||||
|
||||
log.Printf(" ✅ Statut mis à jour: livre → approved")
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 4: Incrémenter le compteur du livreur (ATOMIQUE)
|
||||
// ============================================
|
||||
if livreurAssign.Valid && livreurAssign.String != "" {
|
||||
incrementQuery := `UPDATE users
|
||||
SET livraison = livraison + 1,
|
||||
total = total + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $1 AND role = 'livreur'`
|
||||
|
||||
result, err := tx.Exec(incrementQuery, livreurAssign.String)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur incrémentation livreur: %v", err)
|
||||
// Non bloquant mais on continue dans la transaction
|
||||
} else {
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows > 0 {
|
||||
log.Printf(" ✅ Compteur livreur incrémenté: %s", livreurAssign.String)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ÉTAPE 5: Ajouter un log (DANS la transaction)
|
||||
// ============================================
|
||||
logQuery := `INSERT INTO command_logs (command_id, status, message, actor, created_at)
|
||||
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
|
||||
|
||||
_, err = tx.Exec(logQuery, commandID, "approved",
|
||||
fmt.Sprintf("Livraison approuvée par le client %s", clientUsername),
|
||||
clientUsername)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur ajout log: %v", err)
|
||||
// Non bloquant
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ COMMIT de la transaction
|
||||
// ============================================
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur COMMIT: %v", err)
|
||||
return fmt.Errorf("erreur commit transaction: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("🎉 [ApproveDelivery] SUCCÈS - Commande %d approuvée par %s", commandID, clientUsername)
|
||||
log.Printf(" Workflow: livre → ✅ approved (TRANSACTION COMMITTED)")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
func (d *Database) GetDeliveryIssues(status string) ([]models.DeliveryIssue, error) {
|
||||
query := `
|
||||
SELECT id, command_id, issue_type, description, status,
|
||||
reported_by, COALESCE(resolved_by, ''), COALESCE(resolution, ''),
|
||||
created_at, updated_at
|
||||
FROM delivery_issues
|
||||
`
|
||||
|
||||
var args []interface{}
|
||||
if status != "" {
|
||||
query += " WHERE status = $1"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC"
|
||||
|
||||
rows, err := d.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération problèmes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var issues []models.DeliveryIssue
|
||||
for rows.Next() {
|
||||
var issue models.DeliveryIssue
|
||||
err := rows.Scan(
|
||||
&issue.ID,
|
||||
&issue.CommandID,
|
||||
&issue.IssueType,
|
||||
&issue.Description,
|
||||
&issue.Status,
|
||||
&issue.ReportedBy,
|
||||
&issue.ResolvedBy,
|
||||
&issue.Resolution,
|
||||
&issue.CreatedAt,
|
||||
&issue.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur scan problème: %w", err)
|
||||
}
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
// CreateDeliveryIssue crée un nouveau problème
|
||||
func (d *Database) CreateDeliveryIssue(commandID int, issueType, description, reportedBy string) (*models.DeliveryIssue, error) {
|
||||
query := `
|
||||
INSERT INTO delivery_issues (command_id, issue_type, description, status, reported_by, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'open', $4, NOW(), NOW())
|
||||
RETURNING id, command_id, issue_type, description, status, reported_by, created_at, updated_at
|
||||
`
|
||||
|
||||
var issue models.DeliveryIssue
|
||||
err := d.QueryRow(query, commandID, issueType, description, reportedBy).Scan(
|
||||
&issue.ID,
|
||||
&issue.CommandID,
|
||||
&issue.IssueType,
|
||||
&issue.Description,
|
||||
&issue.Status,
|
||||
&issue.ReportedBy,
|
||||
&issue.CreatedAt,
|
||||
&issue.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur création problème: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Problème créé: ID=%d, Type=%s, Commande=%d", issue.ID, issueType, commandID)
|
||||
return &issue, nil
|
||||
}
|
||||
|
||||
// UpdateDeliveryIssue met à jour un problème
|
||||
func (d *Database) UpdateDeliveryIssue(issueID int, status, resolution, resolvedBy string) error {
|
||||
query := `
|
||||
UPDATE delivery_issues
|
||||
SET status = $1, resolution = $2, resolved_by = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
`
|
||||
|
||||
result, err := d.Exec(query, status, resolution, resolvedBy, issueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour problème: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("problème non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Problème %d mis à jour: status=%s", issueID, status)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
// ============================================
|
||||
// db/delivery_management_db.go
|
||||
// FONCTIONS DB POUR LA GESTION COMPLÈTE DES LIVREURS
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var allowedStatuses = map[string]bool{
|
||||
"available": true,
|
||||
"offline": true,
|
||||
"busy": true,
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📊 STATISTIQUES LIVREUR
|
||||
// ============================================
|
||||
|
||||
// CountDeliveriesByStatus compte les livraisons d'un livreur par statut
|
||||
func (d *Database) CountDeliveriesByStatus(livreurUsername string, statuses string) (int, error) {
|
||||
if statuses == "" {
|
||||
// Cas simple: toutes les livraisons
|
||||
query := `SELECT COUNT(*)
|
||||
FROM commandes
|
||||
WHERE livreur_assign = $1`
|
||||
|
||||
var count int
|
||||
err := d.QueryRow(query, livreurUsername).Scan(&count)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ✅ Valider les statuts
|
||||
cleanStatuses, err := ValidateStatuses(statuses)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Validation échouée: %v", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// ✅ Construire la requête avec IN et placeholders
|
||||
placeholders := make([]string, len(cleanStatuses))
|
||||
args := []interface{}{livreurUsername}
|
||||
|
||||
for i, status := range cleanStatuses {
|
||||
placeholders[i] = fmt.Sprintf("$%d", i+2)
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`SELECT COUNT(*)
|
||||
FROM commandes
|
||||
WHERE livreur_assign = $1
|
||||
AND status IN (%s)`, strings.Join(placeholders, ","))
|
||||
|
||||
var count int
|
||||
err = d.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CountDeliveries] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur comptage livraisons: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [CountDeliveries] %d livraisons pour %s avec statuts %v",
|
||||
count, livreurUsername, cleanStatuses)
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func ValidateStatuses(statuses string) ([]string, error) {
|
||||
if statuses == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
statusList := strings.Split(statuses, ",")
|
||||
|
||||
// Liste blanche complète
|
||||
validStatusMap := map[string]bool{
|
||||
"pending": true,
|
||||
"support": true,
|
||||
"assigned": true,
|
||||
"en_route": true,
|
||||
"arrived": true,
|
||||
"livre": true,
|
||||
"approved": true,
|
||||
"failed": true,
|
||||
"cancelled": true,
|
||||
}
|
||||
|
||||
var cleanStatuses []string
|
||||
var invalidStatuses []string
|
||||
|
||||
for _, status := range statusList {
|
||||
status = strings.TrimSpace(status)
|
||||
|
||||
// Ignorer les chaînes vides
|
||||
if status == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if validStatusMap[status] {
|
||||
cleanStatuses = append(cleanStatuses, status)
|
||||
} else {
|
||||
invalidStatuses = append(invalidStatuses, status)
|
||||
}
|
||||
}
|
||||
|
||||
// Logger les statuts invalides
|
||||
if len(invalidStatuses) > 0 {
|
||||
log.Printf("⚠️ [ValidateStatuses] Statuts invalides ignorés: %v", invalidStatuses)
|
||||
}
|
||||
|
||||
if len(cleanStatuses) == 0 {
|
||||
return nil, fmt.Errorf("aucun statut valide trouvé dans: %s", statuses)
|
||||
}
|
||||
|
||||
return cleanStatuses, nil
|
||||
}
|
||||
|
||||
// GetLastDeliveryDate récupère la date de la dernière livraison d'un livreur
|
||||
func (d *Database) GetLastDeliveryDate(livreurUsername string) (*time.Time, error) {
|
||||
query := `SELECT MAX(updated_at)
|
||||
FROM commandes
|
||||
WHERE livreur_assign = $1
|
||||
AND status = 'approved'`
|
||||
|
||||
var lastDate sql.NullTime
|
||||
err := d.QueryRow(query, livreurUsername).Scan(&lastDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération dernière livraison: %w", err)
|
||||
}
|
||||
|
||||
if !lastDate.Valid {
|
||||
return nil, nil // Aucune livraison
|
||||
}
|
||||
|
||||
t := lastDate.Time
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// GetCurrentCommand récupère l'ID de la commande en cours d'un livreur
|
||||
func (d *Database) GetCurrentCommand(livreurUsername string) (int, error) {
|
||||
// Récupérer depuis Redis
|
||||
currentKey := fmt.Sprintf("delivery:current:%s", livreurUsername)
|
||||
currentIDStr, err := Redis.Get(RedisCtx, currentKey).Result()
|
||||
if err != nil {
|
||||
return 0, nil // Pas de commande en cours
|
||||
}
|
||||
|
||||
var currentID int
|
||||
_, err = fmt.Sscanf(currentIDStr, "%d", ¤tID)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return currentID, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📜 HISTORIQUE DES LIVRAISONS
|
||||
// ============================================
|
||||
|
||||
// GetDeliveryPersonHistory récupère l'historique paginé des livraisons d'un livreur
|
||||
func (d *Database) GetDeliveryPersonHistory(livreurUsername string, limit, offset int) ([]map[string]interface{}, error) {
|
||||
query := `
|
||||
SELECT
|
||||
c.id as command_id,
|
||||
c.username as client,
|
||||
c.status,
|
||||
c.adresse,
|
||||
c.total_prix,
|
||||
c.created_at as assigned_at,
|
||||
c.updated_at as completed_at
|
||||
FROM commandes c
|
||||
WHERE c.livreur_assign = $1
|
||||
ORDER BY c.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
||||
rows, err := d.Query(query, livreurUsername, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération historique: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var history []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var commandID int
|
||||
var client, status, adresse string
|
||||
var totalPrix float64
|
||||
var assignedAt, completedAt time.Time
|
||||
|
||||
err := rows.Scan(
|
||||
&commandID,
|
||||
&client,
|
||||
&status,
|
||||
&adresse,
|
||||
&totalPrix,
|
||||
&assignedAt,
|
||||
&completedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur scan historique: %w", err)
|
||||
}
|
||||
|
||||
// Calculer la durée de livraison si complétée
|
||||
var deliveryTime float64
|
||||
if status == "approved" || status == "livre" {
|
||||
deliveryTime = completedAt.Sub(assignedAt).Minutes()
|
||||
}
|
||||
|
||||
entry := map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"client": client,
|
||||
"status": status,
|
||||
"adresse": adresse,
|
||||
"total_prix": totalPrix,
|
||||
"assigned_at": assignedAt.Format("2006-01-02 15:04:05"),
|
||||
"completed_at": completedAt.Format("2006-01-02 15:04:05"),
|
||||
"delivery_time": deliveryTime,
|
||||
}
|
||||
|
||||
history = append(history, entry)
|
||||
}
|
||||
|
||||
return history, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📍 GESTION POSITION GPS
|
||||
// ============================================
|
||||
// ⚠️ REMARQUE: Les fonctions UpdateDeliveryPersonLocation et GetDeliveryPersonLocation
|
||||
// sont déjà définies dans db/delivery_db.go
|
||||
// Nous réutilisons ces fonctions existantes au lieu de les redéfinir ici
|
||||
|
||||
// ============================================
|
||||
// 🔄 GESTION STATUT LIVREUR
|
||||
// ============================================
|
||||
|
||||
// GetDeliveryPersonStatus récupère le statut d'un livreur
|
||||
func (d *Database) GetDeliveryPersonStatus(livreurUsername string) (string, error) {
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
|
||||
|
||||
status, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
// Statut par défaut si non trouvé
|
||||
return "offline", nil
|
||||
}
|
||||
|
||||
return status, nil
|
||||
}
|
||||
|
||||
// UpdateDeliveryPersonStatus met à jour le statut d'un livreur
|
||||
|
||||
func (d *Database) UpdateDeliveryPersonStatus(livreurUsername string, status string) error {
|
||||
// 🔹 Valider le statut
|
||||
if !allowedStatuses[status] {
|
||||
return fmt.Errorf("statut invalide: %s", status)
|
||||
}
|
||||
|
||||
log.Printf("🔄 [UpdateStatus] Mise à jour: %s → %s", livreurUsername, status)
|
||||
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", livreurUsername)
|
||||
|
||||
err := Redis.Set(RedisCtx, statusKey, status, 0).Err()
|
||||
if err != nil {
|
||||
log.Printf("❌ [UpdateStatus] Erreur Redis: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [UpdateStatus] Statut mis à jour pour %s: %s", livreurUsername, status)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📦 GESTION QUEUE LIVREUR
|
||||
// ============================================
|
||||
|
||||
// GetDeliverymanQueueSize récupère la taille de la queue d'un livreur
|
||||
func (d *Database) GetDeliverymanQueueSize(livreurUsername string) (int, error) {
|
||||
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
|
||||
|
||||
size, err := Redis.LLen(RedisCtx, queueKey).Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération taille queue: %w", err)
|
||||
}
|
||||
|
||||
return int(size), nil
|
||||
}
|
||||
|
||||
// GetDeliverymanQueue récupère la queue complète d'un livreur
|
||||
func (d *Database) GetDeliverymanQueue(livreurUsername string) ([]int, error) {
|
||||
queueKey := fmt.Sprintf("delivery:queue:%s", livreurUsername)
|
||||
|
||||
commands, err := Redis.LRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération queue: %w", err)
|
||||
}
|
||||
|
||||
var queue []int
|
||||
for _, cmdStr := range commands {
|
||||
var cmdID int
|
||||
fmt.Sscanf(cmdStr, "%d", &cmdID)
|
||||
queue = append(queue, cmdID)
|
||||
}
|
||||
|
||||
return queue, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔧 FONCTIONS UTILITAIRES
|
||||
// ============================================
|
||||
|
||||
// UpdateCommandLivreur met à jour le livreur assigné à une commande
|
||||
func (d *Database) UpdateCommandLivreur(commandID int, livreurUsername string) error {
|
||||
query := `UPDATE commandes
|
||||
SET livreur_assign = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`
|
||||
|
||||
result, err := d.Exec(query, livreurUsername, commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour livreur: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("commande non trouvée")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📊 STATISTIQUES SYSTÈME
|
||||
// ============================================
|
||||
|
||||
// GetAllDeliveryPersonsStats récupère les stats de tous les livreurs
|
||||
func (d *Database) GetAllDeliveryPersonsStats() ([]map[string]interface{}, error) {
|
||||
// Récupérer tous les livreurs
|
||||
livreurs, err := d.GetAvailableDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
|
||||
}
|
||||
|
||||
var stats []map[string]interface{}
|
||||
|
||||
for _, livreur := range livreurs {
|
||||
username := livreur["username"].(string)
|
||||
|
||||
// Compter les livraisons
|
||||
totalDeliveries, _ := d.CountDeliveriesByStatus(username, "")
|
||||
completedDeliveries, _ := d.CountDeliveriesByStatus(username, "approved")
|
||||
queueSize, _ := d.GetDeliverymanQueueSize(username)
|
||||
status, _ := d.GetDeliveryPersonStatus(username)
|
||||
|
||||
statEntry := map[string]interface{}{
|
||||
"username": username,
|
||||
"total_deliveries": totalDeliveries,
|
||||
"completed_deliveries": completedDeliveries,
|
||||
"queue_size": queueSize,
|
||||
"status": status,
|
||||
}
|
||||
|
||||
stats = append(stats, statEntry)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔍 RECHERCHE & FILTRAGE
|
||||
// ============================================
|
||||
|
||||
// GetDeliveryPersonsByStatus récupère les livreurs par statut
|
||||
func (d *Database) GetDeliveryPersonsByStatus(status string) ([]string, error) {
|
||||
// Récupérer tous les livreurs
|
||||
livreurs, err := d.GetAvailableDeliveryPersons()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération livreurs: %w", err)
|
||||
}
|
||||
|
||||
var filteredLivreurs []string
|
||||
|
||||
for _, livreur := range livreurs {
|
||||
username := livreur["username"].(string)
|
||||
currentStatus, _ := d.GetDeliveryPersonStatus(username)
|
||||
|
||||
if currentStatus == status {
|
||||
filteredLivreurs = append(filteredLivreurs, username)
|
||||
}
|
||||
}
|
||||
|
||||
return filteredLivreurs, nil
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsCount compte les livreurs disponibles
|
||||
func (d *Database) GetAvailableDeliveryPersonsCount() (int, error) {
|
||||
availableLivreurs, err := d.GetDeliveryPersonsByStatus("available")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return len(availableLivreurs), nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🗑️ SUPPRESSION & NETTOYAGE
|
||||
// ============================================
|
||||
|
||||
// ClearDeliveryPersonData supprime toutes les données d'un livreur (admin uniquement)
|
||||
func (d *Database) ClearDeliveryPersonData(livreurUsername string) error {
|
||||
log.Printf("🗑️ [ClearDeliveryData] Nettoyage données pour: %s", livreurUsername)
|
||||
|
||||
// Supprimer de Redis
|
||||
keys := []string{
|
||||
fmt.Sprintf("delivery:status:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:location:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:queue:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:queue:size:%s", livreurUsername),
|
||||
fmt.Sprintf("delivery:current:%s", livreurUsername),
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
err := Redis.Del(RedisCtx, key).Err()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [ClearDeliveryData] Erreur suppression clé %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [ClearDeliveryData] Données nettoyées pour %s", livreurUsername)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// ============================================
|
||||
// db/gps_links.go
|
||||
// Génération de liens GPS vers différentes plateformes
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// MapLinks contient les liens vers différentes plateformes de cartographie
|
||||
type MapLinks struct {
|
||||
GoogleMaps string `json:"google_maps"`
|
||||
GoogleMapsApp string `json:"google_maps_app"`
|
||||
Waze string `json:"waze"`
|
||||
WazeApp string `json:"waze_app"`
|
||||
AppleMaps string `json:"apple_maps"`
|
||||
OpenStreetMap string `json:"openstreetmap"`
|
||||
BingMaps string `json:"bing_maps"`
|
||||
HereMaps string `json:"here_maps"`
|
||||
}
|
||||
|
||||
// GenerateMapLinks génère tous les liens de cartes pour une position GPS
|
||||
func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks {
|
||||
// Encoder le label pour l'URL
|
||||
encodedLabel := url.QueryEscape(label)
|
||||
|
||||
return MapLinks{
|
||||
// Google Maps (Web)
|
||||
GoogleMaps: fmt.Sprintf(
|
||||
"https://www.google.com/maps?q=%.6f,%.6f&label=%s",
|
||||
lat, lon, encodedLabel,
|
||||
),
|
||||
|
||||
// Google Maps (App - Deep link)
|
||||
GoogleMapsApp: fmt.Sprintf(
|
||||
"https://maps.google.com/?q=%.6f,%.6f",
|
||||
lat, lon,
|
||||
),
|
||||
|
||||
// Waze (Web)
|
||||
Waze: fmt.Sprintf(
|
||||
"https://www.waze.com/ul?ll=%.6f,%.6f&navigate=yes",
|
||||
lat, lon,
|
||||
),
|
||||
|
||||
// Waze (App - Deep link)
|
||||
WazeApp: fmt.Sprintf(
|
||||
"waze://?ll=%.6f,%.6f&navigate=yes",
|
||||
lat, lon,
|
||||
),
|
||||
|
||||
// Apple Maps (iOS/macOS)
|
||||
AppleMaps: fmt.Sprintf(
|
||||
"http://maps.apple.com/?ll=%.6f,%.6f&q=%s",
|
||||
lat, lon, encodedLabel,
|
||||
),
|
||||
|
||||
// OpenStreetMap
|
||||
OpenStreetMap: fmt.Sprintf(
|
||||
"https://www.openstreetmap.org/?mlat=%.6f&mlon=%.6f#map=16/%.6f/%.6f",
|
||||
lat, lon, lat, lon,
|
||||
),
|
||||
|
||||
// Bing Maps
|
||||
BingMaps: fmt.Sprintf(
|
||||
"https://www.bing.com/maps?cp=%.6f~%.6f&lvl=16",
|
||||
lat, lon,
|
||||
),
|
||||
|
||||
// HERE Maps
|
||||
HereMaps: fmt.Sprintf(
|
||||
"https://wego.here.com/?map=%.6f,%.6f,16,normal",
|
||||
lat, lon,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateNavigationLink génère un lien de navigation depuis une origine vers une destination
|
||||
func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string {
|
||||
switch platform {
|
||||
case "google":
|
||||
return fmt.Sprintf(
|
||||
"https://www.google.com/maps/dir/?api=1&origin=%.6f,%.6f&destination=%.6f,%.6f&travelmode=driving",
|
||||
fromLat, fromLon, toLat, toLon,
|
||||
)
|
||||
|
||||
case "waze":
|
||||
return fmt.Sprintf(
|
||||
"https://www.waze.com/ul?ll=%.6f,%.6f&navigate=yes&from=%.6f,%.6f",
|
||||
toLat, toLon, fromLat, fromLon,
|
||||
)
|
||||
|
||||
case "apple":
|
||||
return fmt.Sprintf(
|
||||
"http://maps.apple.com/?saddr=%.6f,%.6f&daddr=%.6f,%.6f",
|
||||
fromLat, fromLon, toLat, toLon,
|
||||
)
|
||||
|
||||
default:
|
||||
return fmt.Sprintf(
|
||||
"https://www.google.com/maps/dir/?api=1&origin=%.6f,%.6f&destination=%.6f,%.6f",
|
||||
fromLat, fromLon, toLat, toLon,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateMapLinksForCommand génère les liens de navigation pour une commande
|
||||
// (depuis la position du livreur vers la destination de la commande)
|
||||
func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) {
|
||||
// 1. Récupérer la position du livreur
|
||||
livreurLat, livreurLon, err := d.GetDeliveryPersonLocation(deliverymanUsername)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("position livreur non disponible: %w", err)
|
||||
}
|
||||
|
||||
// 2. Récupérer la destination de la commande
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commande non trouvée: %w", err)
|
||||
}
|
||||
|
||||
var destLat, destLon float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
destLat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lonVal, ok := command["dest_longitude"].(float64); ok {
|
||||
destLon = lonVal
|
||||
}
|
||||
}
|
||||
|
||||
if destLat == 0 && destLon == 0 {
|
||||
return nil, fmt.Errorf("coordonnées destination invalides")
|
||||
}
|
||||
|
||||
// 3. Générer les liens de navigation
|
||||
return map[string]string{
|
||||
"google_maps": d.GenerateNavigationLink(livreurLat, livreurLon, destLat, destLon, "google"),
|
||||
"waze": d.GenerateNavigationLink(livreurLat, livreurLon, destLat, destLon, "waze"),
|
||||
"apple_maps": d.GenerateNavigationLink(livreurLat, livreurLon, destLat, destLon, "apple"),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// ============================================
|
||||
// db/commands_history.go
|
||||
// ============================================
|
||||
// Fonctions pour l'historique des commandes terminées
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetCompletedCommandsByUsername récupère toutes les commandes terminées (approved) d'un utilisateur
|
||||
// ✅ Retourne uniquement les commandes avec status = "approved"
|
||||
// ✅ Ordonnées par date de création décroissante (plus récentes en premier)
|
||||
func (d *Database) GetCompletedCommandsByUsername(username string) ([]map[string]interface{}, error) {
|
||||
log.Printf("📚 [GetCompletedCommands] START - username=%s", username)
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
status,
|
||||
adresse,
|
||||
total_prix,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = $1 AND status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := d.Query(query, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCompletedCommands] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes terminées: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var commands []map[string]interface{}
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username, status, adresse string
|
||||
var livreurAssign sql.NullString
|
||||
var totalPrix float64
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
&username,
|
||||
&status,
|
||||
&adresse,
|
||||
&totalPrix,
|
||||
&livreurAssign,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCompletedCommands] Erreur scan: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors du scan de la commande: %w", err)
|
||||
}
|
||||
|
||||
command := map[string]interface{}{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"status": status,
|
||||
"adresse": adresse,
|
||||
"total_prix": totalPrix,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}
|
||||
|
||||
// Ajouter livreur_assign seulement s'il n'est pas NULL
|
||||
if livreurAssign.Valid {
|
||||
command["livreur_assign"] = livreurAssign.String
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Printf("❌ [GetCompletedCommands] Erreur itération: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetCompletedCommands] %d commandes terminées trouvées pour %s", len(commands), username)
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetCompletedCommandsWithItems récupère les commandes terminées avec leurs items
|
||||
// ✅ Retourne les commandes approved avec tous les détails
|
||||
func (d *Database) GetCompletedCommandsWithItems(username string) ([]map[string]interface{}, error) {
|
||||
log.Printf("📚 [GetCompletedWithItems] START - username=%s", username)
|
||||
|
||||
// 1. Récupérer les commandes terminées
|
||||
commands, err := d.GetCompletedCommandsByUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Pour chaque commande, récupérer les items
|
||||
var enrichedCommands []map[string]interface{}
|
||||
|
||||
for _, command := range commands {
|
||||
commandID, ok := command["id"].(int)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Récupérer les items
|
||||
items, err := d.GetCommandItems(commandID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetCompletedWithItems] Erreur items pour cmd %d: %v", commandID, err)
|
||||
items = []map[string]interface{}{}
|
||||
}
|
||||
|
||||
// Enrichir la commande
|
||||
enrichedCommand := make(map[string]interface{})
|
||||
for k, v := range command {
|
||||
enrichedCommand[k] = v
|
||||
}
|
||||
enrichedCommand["items"] = items
|
||||
enrichedCommand["items_count"] = len(items)
|
||||
|
||||
enrichedCommands = append(enrichedCommands, enrichedCommand)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetCompletedWithItems] %d commandes enrichies", len(enrichedCommands))
|
||||
return enrichedCommands, nil
|
||||
}
|
||||
|
||||
// GetCommandsStatsByUsername récupère les statistiques des commandes d'un utilisateur
|
||||
// ✅ Compte total, approved, pending, cancelled, etc.
|
||||
func (d *Database) GetCommandsStatsByUsername(username string) (map[string]interface{}, error) {
|
||||
log.Printf("📊 [GetCommandsStats] START - username=%s", username)
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'approved') as approved_count,
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'assigned') as assigned_count,
|
||||
COUNT(*) FILTER (WHERE status = 'en_route') as en_route_count,
|
||||
COUNT(*) FILTER (WHERE status = 'livre') as livre_count,
|
||||
COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
|
||||
COUNT(*) as total_count,
|
||||
COALESCE(SUM(total_prix) FILTER (WHERE status = 'approved'), 0) as total_spent
|
||||
FROM commandes
|
||||
WHERE username = $1
|
||||
`
|
||||
|
||||
var approvedCount, pendingCount, assignedCount, enRouteCount, livreCount, cancelledCount, totalCount int
|
||||
var totalSpent float64
|
||||
|
||||
err := d.QueryRow(query, username).Scan(
|
||||
&approvedCount,
|
||||
&pendingCount,
|
||||
&assignedCount,
|
||||
&enRouteCount,
|
||||
&livreCount,
|
||||
&cancelledCount,
|
||||
&totalCount,
|
||||
&totalSpent,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCommandsStats] Erreur: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des statistiques: %w", err)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"approved_count": approvedCount,
|
||||
"pending_count": pendingCount,
|
||||
"assigned_count": assignedCount,
|
||||
"en_route_count": enRouteCount,
|
||||
"livre_count": livreCount,
|
||||
"cancelled_count": cancelledCount,
|
||||
"total_count": totalCount,
|
||||
"total_spent": totalSpent,
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetCommandsStats] Stats calculées: total=%d, approved=%d, spent=%.2f€",
|
||||
totalCount, approvedCount, totalSpent)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetCommandsByStatus récupère les commandes d'un utilisateur par statut
|
||||
// ✅ Permet de filtrer par un statut spécifique
|
||||
func (d *Database) GetCommandsByStatus(username, status string) ([]map[string]interface{}, error) {
|
||||
log.Printf("📋 [GetCommandsByStatus] START - username=%s, status=%s", username, status)
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
status,
|
||||
adresse,
|
||||
total_prix,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = $1 AND status = $2
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
rows, err := d.Query(query, username, status)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCommandsByStatus] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var commands []map[string]interface{}
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username, status, adresse string
|
||||
var livreurAssign sql.NullString
|
||||
var totalPrix float64
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
&username,
|
||||
&status,
|
||||
&adresse,
|
||||
&totalPrix,
|
||||
&livreurAssign,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCommandsByStatus] Erreur scan: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors du scan: %w", err)
|
||||
}
|
||||
|
||||
command := map[string]interface{}{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"status": status,
|
||||
"adresse": adresse,
|
||||
"total_prix": totalPrix,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}
|
||||
|
||||
if livreurAssign.Valid {
|
||||
command["livreur_assign"] = livreurAssign.String
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Printf("❌ [GetCommandsByStatus] Erreur itération: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de l'itération: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetCommandsByStatus] %d commandes trouvées", len(commands))
|
||||
return commands, nil
|
||||
}
|
||||
|
||||
// GetRecentCompletedOrders récupère les N dernières commandes terminées d'un utilisateur
|
||||
// ✅ Utile pour afficher les dernières commandes dans le dashboard
|
||||
func (d *Database) GetRecentCompletedOrders(username string, limit int) ([]map[string]interface{}, error) {
|
||||
log.Printf("📚 [GetRecentCompleted] START - username=%s, limit=%d", username, limit)
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
status,
|
||||
adresse,
|
||||
total_prix,
|
||||
livreur_assign,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commandes
|
||||
WHERE username = $1 AND status = 'approved'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
rows, err := d.Query(query, username, limit)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetRecentCompleted] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var commands []map[string]interface{}
|
||||
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var username, status, adresse string
|
||||
var livreurAssign sql.NullString
|
||||
var totalPrix float64
|
||||
var createdAt, updatedAt time.Time
|
||||
|
||||
err := rows.Scan(
|
||||
&id,
|
||||
&username,
|
||||
&status,
|
||||
&adresse,
|
||||
&totalPrix,
|
||||
&livreurAssign,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetRecentCompleted] Erreur scan: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors du scan: %w", err)
|
||||
}
|
||||
|
||||
command := map[string]interface{}{
|
||||
"id": id,
|
||||
"username": username,
|
||||
"status": status,
|
||||
"adresse": adresse,
|
||||
"total_prix": totalPrix,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
}
|
||||
|
||||
if livreurAssign.Valid {
|
||||
command["livreur_assign"] = livreurAssign.String
|
||||
} else {
|
||||
command["livreur_assign"] = nil
|
||||
}
|
||||
|
||||
commands = append(commands, command)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Printf("❌ [GetRecentCompleted] Erreur itération: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de l'itération: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetRecentCompleted] %d commandes récentes trouvées", len(commands))
|
||||
return commands, nil
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// STRUCTURES
|
||||
// ============================================
|
||||
|
||||
// Database encapsule la connexion à la base de données
|
||||
type Database struct {
|
||||
*sql.DB
|
||||
}
|
||||
|
||||
// DB est l'instance globale de la base de données
|
||||
var DB *Database
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION DE LA BASE DE DONNÉES
|
||||
// ============================================
|
||||
|
||||
// InitDB initialise la connexion à PostgreSQL et crée les tables
|
||||
func InitDB() *Database {
|
||||
// Récupérer les paramètres de connexion
|
||||
host := getEnv("DB_HOST", "localhost")
|
||||
port := getEnv("DB_PORT", "5432")
|
||||
user := getEnv("DB_USER", "postgres")
|
||||
password := getEnv("DB_PASSWORD", "postgres")
|
||||
dbname := getEnv("DB_NAME", "gestion_db")
|
||||
sslmode := getEnv("DB_SSLMODE", "disable")
|
||||
|
||||
// Construire la chaîne de connexion
|
||||
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s",
|
||||
host, port, user, password, dbname, sslmode)
|
||||
|
||||
// Ouvrir la connexion
|
||||
db, err := sql.Open("postgres", connStr)
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur lors de l'ouverture de la base de données: %v", err)
|
||||
}
|
||||
|
||||
// Configuration du pool de connexions
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
|
||||
// Tester la connexion
|
||||
if err = db.Ping(); err != nil {
|
||||
log.Fatalf("❌ Erreur de connexion à la base de données: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Connexion à PostgreSQL établie avec succès")
|
||||
|
||||
// Créer l'instance Database
|
||||
database := &Database{db}
|
||||
|
||||
// Assigner à la variable globale
|
||||
DB = database
|
||||
|
||||
// Créer les tables
|
||||
if err = database.createTables(); err != nil {
|
||||
log.Fatalf("❌ Erreur lors de la création des tables: %v", err)
|
||||
}
|
||||
|
||||
log.Println("✅ Tables créées avec succès")
|
||||
|
||||
// Lancer le nettoyage périodique des tokens expirés
|
||||
go database.cleanExpiredTokensPeriodically()
|
||||
|
||||
return database
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRÉATION DES TABLES
|
||||
// ============================================
|
||||
|
||||
// createTables crée toutes les tables nécessaires
|
||||
func (db *Database) createTables() error {
|
||||
queries := []string{
|
||||
|
||||
// ============================
|
||||
// TABLE users
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'user',
|
||||
total NUMERIC(10,2) DEFAULT 0.0,
|
||||
livraison NUMERIC(10,2) DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE clients - ✅ AVEC COLONNES DE TRACKING DES ANNULATIONS
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
nom VARCHAR(100) NOT NULL,
|
||||
prenom VARCHAR(100) NOT NULL,
|
||||
telephone VARCHAR(20) NOT NULL UNIQUE,
|
||||
command INTEGER DEFAULT 0,
|
||||
point INTEGER DEFAULT 0,
|
||||
point_zipette INTEGER DEFAULT 0,
|
||||
amende NUMERIC(10,2) DEFAULT 0.0,
|
||||
cancel_commande INTEGER DEFAULT 0,
|
||||
cancellations_count INTEGER DEFAULT 0 NOT NULL,
|
||||
last_penalty_reason TEXT DEFAULT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE jwt_tokens
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS jwt_tokens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
user_type VARCHAR(20) NOT NULL CHECK (user_type IN ('client', 'admin', 'cabine', 'livreur')),
|
||||
token TEXT NOT NULL,
|
||||
date_save TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
date_fin TIMESTAMP NOT NULL,
|
||||
CHECK (date_fin > date_save)
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE products
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS products (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(100) NOT NULL,
|
||||
stock DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE product_prices
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS product_prices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
quantity INTEGER NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(product_id, quantity)
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE media
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS media (
|
||||
id SERIAL PRIMARY KEY,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE commandes
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS commandes (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'pending',
|
||||
livreur_assign VARCHAR(50),
|
||||
adresse TEXT DEFAULT 'Adresse non spécifiée',
|
||||
total_prix NUMERIC(10,2) NOT NULL DEFAULT 0.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE command_items
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS command_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
produit VARCHAR(255) NOT NULL,
|
||||
product_id INTEGER REFERENCES products(id) ON DELETE SET NULL,
|
||||
quantite INTEGER NOT NULL,
|
||||
prix NUMERIC(10,2) NOT NULL,
|
||||
-- Nouvelles colonnes pour les infos client
|
||||
client_username VARCHAR(255),
|
||||
client_nom VARCHAR(100),
|
||||
client_prenom VARCHAR(100),
|
||||
client_telephone VARCHAR(20),
|
||||
delivery_address TEXT,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE baskets
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS baskets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||
quantity INTEGER NOT NULL,
|
||||
price NUMERIC(10,2) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE command_logs
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS command_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
author VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
// ============================
|
||||
// TABLE delivery_issues
|
||||
// ============================
|
||||
`CREATE TABLE IF NOT EXISTS delivery_issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
command_id INTEGER NOT NULL REFERENCES commandes(id) ON DELETE CASCADE,
|
||||
issue_type VARCHAR(50) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'open',
|
||||
reported_by VARCHAR(100) NOT NULL,
|
||||
resolved_by VARCHAR(100),
|
||||
resolution TEXT,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
|
||||
`CREATE TABLE IF NOT EXISTS alerte_policy (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'false',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);`,
|
||||
// ============================
|
||||
// INDEXES - ✅ AJOUT D'INDEX POUR CANCELLATIONS_COUNT
|
||||
// ============================
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_command_id ON command_items(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_client_username ON command_items(client_username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_status ON command_items(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_command_items_produit ON command_items(produit);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_point_zipette ON clients(point_zipette) WHERE point_zipette > 0;`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_user_id ON jwt_tokens(user_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_user_type ON jwt_tokens(user_type);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_token ON jwt_tokens(token);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_jwt_date_fin ON jwt_tokens(date_fin);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_telephone ON clients(telephone);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_cancellations ON clients(cancellations_count);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_clients_amende ON clients(amende) WHERE amende > 0;`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_username ON commandes(username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_status ON commandes(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_cmd_livreur ON commandes(livreur_assign);`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_products_category ON products(category);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_media_product_id ON media(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_price_product_id ON product_prices(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_items_command_id ON command_items(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_items_product_id ON command_items(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_baskets_username ON baskets(username);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_baskets_product_id ON baskets(product_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_logs_command_id ON command_logs(command_id);`,
|
||||
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_command ON delivery_issues(command_id);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_status ON delivery_issues(status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_issues_reported_by ON delivery_issues(reported_by);`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.Exec(query); err != nil {
|
||||
return fmt.Errorf("❌ Erreur SQL: %v\nRequête: %s", err, query)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("✅ Toutes les tables PostgreSQL créées avec succès.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MÉTHODES POUR LES TOKENS JWT
|
||||
// ============================================
|
||||
|
||||
// CleanExpiredTokens supprime les tokens JWT expirés
|
||||
func (d *Database) CleanExpiredTokens() error {
|
||||
query := `DELETE FROM jwt_tokens WHERE date_fin < $1`
|
||||
result, err := d.Exec(query, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
log.Printf("🧹 %d token(s) expiré(s) supprimé(s)", rowsAffected)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanExpiredTokensPeriodically nettoie les tokens expirés toutes les heures
|
||||
func (db *Database) cleanExpiredTokensPeriodically() {
|
||||
ticker := time.NewTicker(1 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
if err := db.CleanExpiredTokens(); err != nil {
|
||||
log.Printf("⚠️ Erreur lors du nettoyage des tokens: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getEnv récupère une variable d'environnement ou retourne une valeur par défaut
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) SaveToken(userID int, userType string, token string, expiresAt time.Time) error {
|
||||
// Valider le user_type
|
||||
validTypes := map[string]bool{
|
||||
"client": true,
|
||||
"admin": true,
|
||||
"cabine": true,
|
||||
"livreur": true,
|
||||
}
|
||||
|
||||
if !validTypes[userType] {
|
||||
return fmt.Errorf("type d'utilisateur invalide: %s", userType)
|
||||
}
|
||||
|
||||
query := `INSERT INTO jwt_tokens (user_id, user_type, token, date_save, date_fin)
|
||||
VALUES ($1, $2, $3, CURRENT_TIMESTAMP, $4)`
|
||||
|
||||
_, err := d.Exec(query, userID, userType, token, expiresAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de l'enregistrement du token: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Token enregistré pour %s ID: %d", userType, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsTokenValid vérifie si un token existe et n'est pas expiré
|
||||
func (d *Database) IsTokenValid(token string) (bool, error) {
|
||||
query := `SELECT COUNT(*) FROM jwt_tokens
|
||||
WHERE token = $1 AND date_fin > $2`
|
||||
|
||||
var count int
|
||||
err := d.QueryRow(query, token, time.Now()).Scan(&count)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("erreur lors de la vérification du token: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// RevokeToken révoque un token (le supprime de la base)
|
||||
func (d *Database) RevokeToken(token string) error {
|
||||
query := `DELETE FROM jwt_tokens WHERE token = $1`
|
||||
|
||||
result, err := d.Exec(query, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la révocation du token: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected > 0 {
|
||||
log.Printf("✅ Token révoqué avec succès")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeAllUserTokens révoque tous les tokens d'un utilisateur
|
||||
func (d *Database) RevokeAllUserTokens(userID int, userType string) error {
|
||||
query := `DELETE FROM jwt_tokens WHERE user_id = $1 AND user_type = $2`
|
||||
|
||||
result, err := d.Exec(query, userID, userType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la révocation des tokens: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
log.Printf("✅ %d token(s) révoqué(s) pour %s ID: %d", rowsAffected, userType, userID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserActiveTokens récupère tous les tokens actifs d'un utilisateur
|
||||
func (d *Database) GetUserActiveTokens(userID int, userType string) ([]map[string]interface{}, error) {
|
||||
query := `SELECT id, token, date_save, date_fin
|
||||
FROM jwt_tokens
|
||||
WHERE user_id = $1 AND user_type = $2 AND date_fin > $3
|
||||
ORDER BY date_save DESC`
|
||||
|
||||
rows, err := d.Query(query, userID, userType, time.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des tokens: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var token string
|
||||
var dateSave, dateFin time.Time
|
||||
|
||||
err := rows.Scan(&id, &token, &dateSave, &dateFin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan: %w", err)
|
||||
}
|
||||
|
||||
tokens = append(tokens, map[string]interface{}{
|
||||
"id": id,
|
||||
"token": token[:20] + "...", // Tronquer pour la sécurité
|
||||
"date_save": dateSave,
|
||||
"date_fin": dateFin,
|
||||
"user_type": userType,
|
||||
})
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetTokenInfo(token string) (map[string]interface{}, error) {
|
||||
query := `SELECT user_id, user_type, date_save, date_fin
|
||||
FROM jwt_tokens
|
||||
WHERE token = $1 AND date_fin > $2`
|
||||
|
||||
var userID int
|
||||
var userType string
|
||||
var dateSave, dateFin time.Time
|
||||
|
||||
err := d.QueryRow(query, token, time.Now()).Scan(&userID, &userType, &dateSave, &dateFin)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("token non trouvé ou expiré")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des infos du token: %w", err)
|
||||
}
|
||||
|
||||
tokenInfo := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"user_type": userType,
|
||||
"date_save": dateSave,
|
||||
"date_fin": dateFin,
|
||||
}
|
||||
|
||||
return tokenInfo, nil
|
||||
}
|
||||
|
||||
func (d *Database) CountActiveTokensByType() (map[string]int, error) {
|
||||
query := `SELECT user_type, COUNT(*) as count
|
||||
FROM jwt_tokens
|
||||
WHERE date_fin > $1
|
||||
GROUP BY user_type`
|
||||
|
||||
rows, err := d.Query(query, time.Now())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du comptage des tokens: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
counts := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var userType string
|
||||
var count int
|
||||
if err := rows.Scan(&userType, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts[userType] = count
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// VALIDATION HELPERS
|
||||
// ============================================
|
||||
|
||||
// validateMediaID vérifie la validité d'un ID média
|
||||
func validateMediaID(mediaID int) error {
|
||||
if mediaID <= 0 {
|
||||
return fmt.Errorf("ID média invalide: %d", mediaID)
|
||||
}
|
||||
if mediaID > 2147483647 { // Max int32
|
||||
return fmt.Errorf("ID média trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateProductID vérifie la validité d'un ID produit
|
||||
func validateProductID(productID int) error {
|
||||
if productID <= 0 {
|
||||
return fmt.Errorf("ID produit invalide: %d", productID)
|
||||
}
|
||||
if productID > 2147483647 {
|
||||
return fmt.Errorf("ID produit trop grand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateMediaType vérifie le type de média
|
||||
func validateMediaType(mediaType string) error {
|
||||
validTypes := []string{"image", "video"}
|
||||
|
||||
mediaType = strings.ToLower(strings.TrimSpace(mediaType))
|
||||
|
||||
for _, valid := range validTypes {
|
||||
if mediaType == valid {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("type de média invalide: %s (autorisé: image, video)", mediaType)
|
||||
}
|
||||
|
||||
// validateMediaURL vérifie la sécurité de l'URL
|
||||
func validateMediaURL(url string) error {
|
||||
if len(url) == 0 {
|
||||
return fmt.Errorf("URL vide")
|
||||
}
|
||||
|
||||
if len(url) > 500 {
|
||||
return fmt.Errorf("URL trop longue (max 500 caractères)")
|
||||
}
|
||||
|
||||
// ✅ PROTECTION PATH TRAVERSAL
|
||||
if strings.Contains(url, "..") {
|
||||
return fmt.Errorf("path traversal détecté dans l'URL")
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE L'URL COMMENCE PAR /uploads/
|
||||
if !strings.HasPrefix(url, "/uploads/") {
|
||||
return fmt.Errorf("URL doit commencer par /uploads/")
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QU'IL N'Y A PAS DE CARACTÈRES DANGEREUX
|
||||
dangerousChars := []string{
|
||||
"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\",
|
||||
}
|
||||
|
||||
for _, char := range dangerousChars {
|
||||
if strings.Contains(url, char) {
|
||||
return fmt.Errorf("caractères interdits dans l'URL")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CREATE MEDIA - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (db *Database) CreateMedia(media interface{}) error {
|
||||
log.Printf("🔒 [CreateMedia] START - Type: %T", media)
|
||||
|
||||
type MediaInterface interface {
|
||||
GetProductID() int
|
||||
GetType() string
|
||||
GetURL() string
|
||||
SetID(int)
|
||||
}
|
||||
|
||||
// ✅ TYPE ASSERTION SÉCURISÉE
|
||||
m, ok := media.(MediaInterface)
|
||||
if !ok {
|
||||
// Vérifier si c'est un pointeur vers models.Media
|
||||
if mediaPtr, isPtr := media.(*models.Media); isPtr {
|
||||
m = mediaPtr
|
||||
ok = true
|
||||
} else {
|
||||
log.Printf("❌ [CreateMedia] Type invalide: %T", media)
|
||||
return fmt.Errorf("type de média invalide: reçu %T", media)
|
||||
}
|
||||
}
|
||||
|
||||
if !ok {
|
||||
return fmt.Errorf("type de média invalide")
|
||||
}
|
||||
|
||||
// ✅ VALIDATION COMPLÈTE
|
||||
productID := m.GetProductID()
|
||||
mediaType := m.GetType()
|
||||
mediaURL := m.GetURL()
|
||||
|
||||
log.Printf("📋 [CreateMedia] ProductID=%d, Type=%s, URL=%s", productID, mediaType, mediaURL)
|
||||
|
||||
// Valider le product ID
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Valider le type
|
||||
if err := validateMediaType(mediaType); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Valider l'URL
|
||||
if err := validateMediaURL(mediaURL); err != nil {
|
||||
log.Printf("❌ [CreateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
var exists bool
|
||||
checkQuery := `SELECT EXISTS(SELECT 1 FROM products WHERE id = $1)`
|
||||
err := db.QueryRow(checkQuery, productID).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err)
|
||||
return fmt.Errorf("erreur vérification produit: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [CreateMedia] Produit %d n'existe pas", productID)
|
||||
return fmt.Errorf("produit %d n'existe pas", productID)
|
||||
}
|
||||
|
||||
// ✅ INSÉRER LE MÉDIA
|
||||
query := `INSERT INTO media (product_id, url, type, created_at)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id`
|
||||
|
||||
var mediaID int
|
||||
now := time.Now()
|
||||
|
||||
err = db.QueryRow(query, productID, mediaURL, mediaType, now).Scan(&mediaID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err)
|
||||
return fmt.Errorf("erreur création média: %w", err)
|
||||
}
|
||||
|
||||
m.SetID(mediaID)
|
||||
log.Printf("✅ [CreateMedia] Média créé: ID=%d, Type=%s", mediaID, mediaType)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET MEDIA BY ID - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (db *Database) GetMediaByID(mediaID int) (*models.Media, error) {
|
||||
log.Printf("🔍 [GetMediaByID] START - ID=%d", mediaID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateMediaID(mediaID); err != nil {
|
||||
log.Printf("❌ [GetMediaByID] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var media models.Media
|
||||
|
||||
query := `SELECT id, product_id, url, type, created_at
|
||||
FROM media
|
||||
WHERE id = $1`
|
||||
|
||||
err := db.QueryRow(query, mediaID).Scan(
|
||||
&media.ID,
|
||||
&media.ProductID,
|
||||
&media.URL,
|
||||
&media.Type,
|
||||
&media.CreatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("❌ [GetMediaByID] Média %d non trouvé", mediaID)
|
||||
return nil, fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByID] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération média: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetMediaByID] Média trouvé: Type=%s", media.Type)
|
||||
|
||||
return &media, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GET MEDIA BY PRODUCT ID - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (db *Database) GetMediaByProductID(productID int) ([]models.Media, error) {
|
||||
log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := `SELECT id, product_id, url, type, created_at
|
||||
FROM media
|
||||
WHERE product_id = $1
|
||||
ORDER BY id ASC`
|
||||
|
||||
rows, err := db.Query(query, productID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération médias: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var mediaList []models.Media
|
||||
|
||||
for rows.Next() {
|
||||
var media models.Media
|
||||
|
||||
if err := rows.Scan(
|
||||
&media.ID,
|
||||
&media.ProductID,
|
||||
&media.URL,
|
||||
&media.Type,
|
||||
&media.CreatedAt,
|
||||
); err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] Erreur scan: %v", err)
|
||||
return nil, fmt.Errorf("erreur scan média: %w", err)
|
||||
}
|
||||
|
||||
log.Printf(" 🖼️ Media: ID=%d, Type=%s", media.ID, media.Type)
|
||||
mediaList = append(mediaList, media)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
log.Printf("❌ [GetMediaByProductID] Erreur iteration: %v", err)
|
||||
return nil, fmt.Errorf("erreur itération médias: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetMediaByProductID] %d médias trouvés", len(mediaList))
|
||||
|
||||
return mediaList, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UPDATE MEDIA - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (db *Database) UpdateMedia(media *models.Media) error {
|
||||
log.Printf("🔄 [UpdateMedia] START - ID=%d", media.ID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if media == nil {
|
||||
return fmt.Errorf("média nil")
|
||||
}
|
||||
|
||||
if err := validateMediaID(media.ID); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateMediaType(media.Type); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateMediaURL(media.URL); err != nil {
|
||||
log.Printf("❌ [UpdateMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE MÉDIA EXISTE
|
||||
var exists bool
|
||||
checkQuery := `SELECT EXISTS(SELECT 1 FROM media WHERE id = $1)`
|
||||
err := db.QueryRow(checkQuery, media.ID).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UpdateMedia] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification média: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [UpdateMedia] Média %d n'existe pas", media.ID)
|
||||
return fmt.Errorf("média %d non trouvé", media.ID)
|
||||
}
|
||||
|
||||
// ✅ UPDATE
|
||||
query := `UPDATE media
|
||||
SET url = $1, type = $2
|
||||
WHERE id = $3`
|
||||
|
||||
result, err := db.Exec(query, media.URL, media.Type, media.ID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [UpdateMedia] Erreur UPDATE: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour média: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
log.Printf("❌ [UpdateMedia] Aucune ligne affectée")
|
||||
return fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [UpdateMedia] Média %d mis à jour", media.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DELETE MEDIA - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (db *Database) DeleteMedia(mediaID int) error {
|
||||
log.Printf("🗑️ [DeleteMedia] START - ID=%d", mediaID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateMediaID(mediaID); err != nil {
|
||||
log.Printf("❌ [DeleteMedia] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE MÉDIA EXISTE
|
||||
var exists bool
|
||||
checkQuery := `SELECT EXISTS(SELECT 1 FROM media WHERE id = $1)`
|
||||
err := db.QueryRow(checkQuery, mediaID).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DeleteMedia] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification média: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [DeleteMedia] Média %d n'existe pas", mediaID)
|
||||
return fmt.Errorf("média %d non trouvé", mediaID)
|
||||
}
|
||||
|
||||
// ✅ DELETE
|
||||
query := `DELETE FROM media WHERE id = $1`
|
||||
|
||||
result, err := db.Exec(query, mediaID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DeleteMedia] Erreur DELETE: %v", err)
|
||||
return fmt.Errorf("erreur suppression média: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
log.Printf("❌ [DeleteMedia] Aucune ligne affectée")
|
||||
return fmt.Errorf("média non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [DeleteMedia] Média %d supprimé", mediaID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// DELETE MEDIA BY PRODUCT ID - VERSION SÉCURISÉE
|
||||
// ============================================
|
||||
|
||||
func (db *Database) DeleteMediaByProductID(productID int) error {
|
||||
log.Printf("🗑️ [DeleteMediaByProductID] START - ProductID=%d", productID)
|
||||
|
||||
// ✅ VALIDATION
|
||||
if err := validateProductID(productID); err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// ✅ VÉRIFIER QUE LE PRODUIT EXISTE
|
||||
var exists bool
|
||||
checkQuery := `SELECT EXISTS(SELECT 1 FROM products WHERE id = $1)`
|
||||
err := db.QueryRow(checkQuery, productID).Scan(&exists)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Erreur vérification: %v", err)
|
||||
return fmt.Errorf("erreur vérification produit: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Produit %d n'existe pas", productID)
|
||||
return fmt.Errorf("produit %d non trouvé", productID)
|
||||
}
|
||||
|
||||
// ✅ DELETE
|
||||
query := `DELETE FROM media WHERE product_id = $1`
|
||||
|
||||
result, err := db.Exec(query, productID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DeleteMediaByProductID] Erreur DELETE: %v", err)
|
||||
return fmt.Errorf("erreur suppression médias: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
log.Printf("✅ [DeleteMediaByProductID] %d média(s) supprimé(s) pour produit %d",
|
||||
rowsAffected, productID)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) NotifyClient(username string, commandID int, notifType, message string) error {
|
||||
// Sauvegarder la notification dans Redis
|
||||
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) // Expire après 7 jours
|
||||
|
||||
log.Printf("📬 Notification envoyée à %s: %s", username, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDeliveryRating ajoute une note pour un livreur
|
||||
func (d *Database) AddDeliveryRating(livreurUsername string, commandID, rating int, comment string) error {
|
||||
query := `
|
||||
INSERT INTO delivery_ratings (livreur_username, command_id, rating, comment, created_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
`
|
||||
_, err := d.Exec(query, livreurUsername, commandID, rating, comment)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur sauvegarde note livreur: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("⭐ Note %d/5 ajoutée pour livreur %s (commande %d)", rating, livreurUsername, commandID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreateProduct crée un nouveau produit avec ses prix
|
||||
func (db *Database) CreateProduct(product interface{}) error {
|
||||
log.Printf("🔍 [DB CreateProduct] Type reçu: %T", product)
|
||||
log.Printf("🔍 [DB CreateProduct] Valeur: %+v", product)
|
||||
|
||||
type ProductInterface interface {
|
||||
GetName() string
|
||||
GetCategory() string
|
||||
GetDescription() string
|
||||
GetStock() float64 // ← ajouter méthode pour le stock
|
||||
GetPrices() []models.ProductPrice
|
||||
SetID(int)
|
||||
SetCreatedAt(time.Time)
|
||||
SetUpdatedAt(time.Time)
|
||||
}
|
||||
|
||||
p, ok := product.(ProductInterface)
|
||||
if !ok {
|
||||
// Vérifier si c'est un pointeur vers models.Product
|
||||
if prodPtr, isPtr := product.(*models.Product); isPtr {
|
||||
log.Printf("✅ [DB CreateProduct] C'est un *models.Product, utilisons-le directement")
|
||||
p = prodPtr // ça fonctionne maintenant car *models.Product implémente ProductInterface
|
||||
ok = true
|
||||
} else {
|
||||
return fmt.Errorf("type de produit invalide: reçu %T, attendu ProductInterface", product)
|
||||
}
|
||||
}
|
||||
|
||||
if ok {
|
||||
log.Printf("✅ [DB CreateProduct] Assertion réussie!")
|
||||
log.Printf("📦 [DB CreateProduct] Name: %s", p.GetName())
|
||||
log.Printf("📦 [DB CreateProduct] Category: %s", p.GetCategory())
|
||||
log.Printf("📦 [DB CreateProduct] Description: %s", p.GetDescription())
|
||||
log.Printf("📦 [DB CreateProduct] Stock: %.2f", p.GetStock())
|
||||
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`
|
||||
|
||||
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).
|
||||
Scan(&productID, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur INSERT: %v", err)
|
||||
return fmt.Errorf("erreur création produit: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [DB CreateProduct] Produit inséré avec ID: %d", productID)
|
||||
|
||||
// Mettre à jour le produit avec l'ID et les dates
|
||||
p.SetID(productID)
|
||||
p.SetCreatedAt(createdAt)
|
||||
p.SetUpdatedAt(updatedAt)
|
||||
|
||||
// Insérer les prix
|
||||
prices := p.GetPrices()
|
||||
for i, price := range prices {
|
||||
priceQuery := `INSERT INTO product_prices (product_id, quantity, price)
|
||||
VALUES ($1, $2, $3)`
|
||||
_, err := db.Exec(priceQuery, productID, price.Quantity, price.Price)
|
||||
if err != nil {
|
||||
log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err)
|
||||
return fmt.Errorf("erreur insertion prix: %v", err)
|
||||
}
|
||||
log.Printf("✅ [DB CreateProduct] Prix[%d] inséré: quantity=%d, price=%.2f",
|
||||
i, price.Quantity, price.Price)
|
||||
}
|
||||
|
||||
log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", productID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProductByID récupère un produit par son ID avec prices ET stock
|
||||
func (d *Database) GetProductByID(id int) (models.Product, error) {
|
||||
log.Printf("📦 [GetProductByID] START - ID=%d", id)
|
||||
|
||||
var p models.Product
|
||||
|
||||
// ✅ AJOUTER stock dans le SELECT
|
||||
err := d.QueryRow(`
|
||||
SELECT id, name, category, description, stock, 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)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductByID] Erreur query: %v", err)
|
||||
return p, err
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetProductByID] Product scanned: ID=%d, Name=%s, Stock=%.2f",
|
||||
p.ID, p.Name, p.Stock)
|
||||
|
||||
// ✅ CHARGER LES PRICES
|
||||
prices, err := d.GetProductPrices(p.ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetProductByID] Erreur loading prices: %v", err)
|
||||
p.Prices = []models.ProductPrice{} // Tableau vide au lieu de nil
|
||||
} else {
|
||||
p.Prices = prices
|
||||
log.Printf("✅ [GetProductByID] Loaded %d prices for product %d", len(prices), p.ID)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
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
|
||||
FROM products
|
||||
ORDER BY id ASC
|
||||
`)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetAllProducts] Erreur query: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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 {
|
||||
log.Printf("❌ [GetAllProducts] Erreur scan: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetAllProducts] Product scanned: ID=%d, Name=%s, Stock=%.2f", p.ID, p.Name, p.Stock)
|
||||
|
||||
// ✅ CHARGER LES PRICES
|
||||
prices, err := d.GetProductPrices(p.ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", p.ID, err)
|
||||
p.Prices = []models.ProductPrice{}
|
||||
} else {
|
||||
p.Prices = prices
|
||||
log.Printf("✅ [GetAllProducts] Loaded %d prices for product %d", len(prices), p.ID)
|
||||
}
|
||||
|
||||
// ✅ CHARGER LES MÉDIAS (MANQUANT AVANT!)
|
||||
media, err := d.GetMediaByProductID(p.ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", p.ID, err)
|
||||
p.Media = []models.Media{}
|
||||
} else {
|
||||
p.Media = media
|
||||
log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), p.ID)
|
||||
}
|
||||
|
||||
products = append(products, p)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetAllProducts] Total products loaded: %d", len(products))
|
||||
return products, nil
|
||||
}
|
||||
|
||||
// GetProductsByCategory récupère les produits par catégorie avec prices ET stock
|
||||
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
|
||||
FROM products
|
||||
WHERE category = $1
|
||||
ORDER BY created_at DESC
|
||||
`, category)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductsByCategory] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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 {
|
||||
log.Printf("❌ [GetProductsByCategory] Erreur scan: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors du scan d'un produit: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📊 [GetProductsByCategory] Product scanned: ID=%d, Name=%s, Stock=%.2f",
|
||||
p.ID, p.Name, p.Stock)
|
||||
|
||||
// ✅ CHARGER LES PRICES pour chaque produit
|
||||
prices, err := db.GetProductPrices(p.ID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", p.ID, err)
|
||||
p.Prices = []models.ProductPrice{} // Tableau vide au lieu de nil
|
||||
} else {
|
||||
p.Prices = prices
|
||||
log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), p.ID)
|
||||
}
|
||||
|
||||
products = append(products, p)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("❌ [GetProductsByCategory] Erreur iteration: %v", err)
|
||||
return nil, fmt.Errorf("erreur lors de l'itération des produits: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetProductsByCategory] Total products loaded: %d", len(products))
|
||||
return products, nil
|
||||
}
|
||||
|
||||
func (db *Database) UpdateProduct(productID int, product interface{}) error {
|
||||
// À implémenter selon vos besoins
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProduct supprime un produit
|
||||
func (db *Database) DeleteProduct(productID int) error {
|
||||
query := `DELETE FROM products WHERE id = $1`
|
||||
result, err := db.Exec(query, productID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur suppression produit: %v", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("produit introuvable")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductNameByID(productID int) (string, error) {
|
||||
var name string
|
||||
query := `SELECT name FROM products WHERE id = $1`
|
||||
err := d.QueryRow(query, productID).Scan(&name)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
// GetProductPrices récupère tous les prix d'un produit
|
||||
func (db *Database) GetProductPrices(productID int) ([]models.ProductPrice, error) {
|
||||
log.Printf("💰 [GetProductPrices] Loading prices for product %d", productID)
|
||||
|
||||
query := `SELECT id, product_id, quantity, price, created_at
|
||||
FROM product_prices
|
||||
WHERE product_id = $1
|
||||
ORDER BY quantity ASC`
|
||||
|
||||
rows, err := db.Query(query, productID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetProductPrices] Erreur query: %v", err)
|
||||
return nil, fmt.Errorf("erreur récupération prix: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var prices []models.ProductPrice
|
||||
for rows.Next() {
|
||||
var price models.ProductPrice
|
||||
if err := rows.Scan(&price.ID, &price.ProductID, &price.Quantity, &price.Price, &price.CreatedAt); err != nil {
|
||||
log.Printf("❌ [GetProductPrices] Erreur scan: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
log.Printf(" 💰 Price: qty=%d, price=%.2f", price.Quantity, price.Price)
|
||||
prices = append(prices, price)
|
||||
}
|
||||
|
||||
log.Printf("✅ [GetProductPrices] Found %d prices for product %d", len(prices), productID)
|
||||
return prices, nil
|
||||
}
|
||||
|
||||
// CreateProductPrice ajoute un nouveau prix pour un produit
|
||||
func (db *Database) CreateProductPrice(productID, quantity int, price float64) error {
|
||||
query := `INSERT INTO product_prices (product_id, quantity, price)
|
||||
VALUES ($1, $2, $3)`
|
||||
_, err := db.Exec(query, productID, quantity, price)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur création prix: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateProductPrice met à jour un prix
|
||||
func (db *Database) UpdateProductPrice(priceID, quantity int, price float64) error {
|
||||
query := `UPDATE product_prices SET quantity = $1, price = $2 WHERE id = $3`
|
||||
result, err := db.Exec(query, quantity, price, priceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour prix: %v", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProductPrice supprime un prix
|
||||
func (db *Database) DeleteProductPrice(priceID int) error {
|
||||
query := `DELETE FROM product_prices WHERE id = $1`
|
||||
result, err := db.Exec(query, priceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur suppression prix: %v", err)
|
||||
}
|
||||
|
||||
rowsAffected, _ := result.RowsAffected()
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("prix introuvable")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
// ============================================
|
||||
// db/queue_auto_next_db.go
|
||||
// GESTION AUTOMATIQUE PROCHAINE COMMANDE
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
)
|
||||
|
||||
// ProcessNextCommandForDeliveryman traite automatiquement la prochaine commande
|
||||
// Appelé après qu'une commande soit livrée ou annulée
|
||||
func (d *Database) ProcessNextCommandForDeliveryman(deliveryman string) error {
|
||||
log.Printf("🔄 [NEXT_COMMAND] Traitement prochaine commande pour %s", deliveryman)
|
||||
|
||||
// Récupérer la prochaine commande dans sa queue
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
// Pas de commande dans la queue personnelle
|
||||
log.Printf("ℹ️ [NEXT_COMMAND] Aucune commande en queue pour %s", deliveryman)
|
||||
|
||||
// Mettre le livreur en "available"
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
|
||||
// ✅ Mettre à jour le statut basé sur la queue
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("📋 [NEXT_COMMAND] %d commande(s) dans la queue de %s", len(commandIDs), deliveryman)
|
||||
|
||||
// ✅ BOUCLE: Essayer toutes les commandes jusqu'à trouver une valide
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] ID invalide: %s", cmdIDStr)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("🔍 [NEXT_COMMAND] Vérification commande %d (position %d/%d)", commandID, i+1, len(commandIDs))
|
||||
|
||||
// Récupérer les détails de la commande depuis Redis
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] Commande %d: données Redis introuvables - Retrait", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
log.Printf("❌ [NEXT_COMMAND] Commande %d: JSON invalide - Retrait", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ Vérifier le statut de la commande dans la DB
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ [NEXT_COMMAND] Commande %d non trouvée en DB - Retrait de la queue", commandID)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
currentStatus, _ := command["status"].(string)
|
||||
log.Printf("📊 [NEXT_COMMAND] Commande %d: statut = '%s'", commandID, currentStatus)
|
||||
|
||||
// ✅ Si la commande n'est plus assignable, la retirer et passer à la suivante
|
||||
nonAssignableStatuses := []string{"livre", "approved", "cancelled", "disabled", "failed"}
|
||||
isNonAssignable := false
|
||||
for _, s := range nonAssignableStatuses {
|
||||
if currentStatus == s {
|
||||
isNonAssignable = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isNonAssignable {
|
||||
log.Printf("⚠️ [NEXT_COMMAND] Commande %d en statut '%s' - Retrait de la queue", commandID, currentStatus)
|
||||
d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
continue // Essayer la suivante
|
||||
}
|
||||
|
||||
// ✅ Commande valide trouvée !
|
||||
log.Printf("✅ [NEXT_COMMAND] Commande %d prête pour %s (statut: %s)", commandID, deliveryman, currentStatus)
|
||||
|
||||
// Optimiser la queue par proximité si possible
|
||||
go d.OptimizeDeliverymanQueueByProximity(deliveryman)
|
||||
|
||||
// ✅ Mettre à jour le statut basé sur la queue
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
// Ajouter un log
|
||||
d.AddCommandLog(commandID, "next_in_queue",
|
||||
fmt.Sprintf("Commande suivante dans la queue de %s", deliveryman),
|
||||
"system")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ Si on arrive ici, toutes les commandes étaient invalides
|
||||
log.Printf("⚠️ [NEXT_COMMAND] Toutes les commandes de %s étaient invalides - Queue vidée", deliveryman)
|
||||
|
||||
// Mettre le livreur en available
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCommandFromDeliverymanQueue retire une commande spécifique de la queue d'un livreur
|
||||
func (d *Database) RemoveCommandFromDeliverymanQueue(deliveryman string, commandID int) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
log.Printf("📦 [RemoveFromDeliverymanQueue] Retrait cmd %d de la queue de %s", commandID, deliveryman)
|
||||
|
||||
// Retirer de la queue
|
||||
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
log.Printf("❌ [RemoveFromDeliverymanQueue] Erreur: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if result == 0 {
|
||||
log.Printf("⚠️ [RemoveFromDeliverymanQueue] Commande %d non trouvée dans queue", commandID)
|
||||
} else {
|
||||
log.Printf("✅ [RemoveFromDeliverymanQueue] Commande %d retirée", commandID)
|
||||
}
|
||||
|
||||
// Supprimer les données
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupCompletedCommandFromQueue nettoie une commande terminée et prépare la suivante
|
||||
// À appeler depuis les handlers d'annulation et de livraison
|
||||
func (d *Database) CleanupCompletedCommandFromQueue(commandID int, deliveryman string) error {
|
||||
log.Printf("🧹 [CLEANUP] Nettoyage commande %d pour %s", commandID, deliveryman)
|
||||
|
||||
// 1. Retirer la commande de TOUTES les queues (pas seulement celle du livreur)
|
||||
err := d.RemoveCommandFromAllQueues(commandID, deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLEANUP] Erreur retrait commande: %v", err)
|
||||
}
|
||||
|
||||
// 2. Supprimer les caches associés
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
Redis.Del(RedisCtx, destCacheKey)
|
||||
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// 3. Supprimer la clé de données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
log.Printf("✅ [CLEANUP] Commande %d nettoyée complètement", commandID)
|
||||
|
||||
// 4. Traiter la prochaine commande
|
||||
return d.ProcessNextCommandForDeliveryman(deliveryman)
|
||||
}
|
||||
|
||||
// RemoveCommandFromAllQueues retire une commande de toutes les queues Redis
|
||||
func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string) error {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
log.Printf("🗑️ [REMOVE_ALL] Suppression commande %d de toutes les queues", commandID)
|
||||
|
||||
// 1. Queue du livreur spécifique
|
||||
if deliveryman != "" {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
result, err := Redis.ZRem(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Erreur suppression queue livreur: %v", err)
|
||||
} else if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:%s", queueKey)
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
} else {
|
||||
log.Printf("ℹ️ [REMOVE_ALL] Commande %d n'était pas dans queue:%s", commandID, queueKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Queue générale
|
||||
removed := false
|
||||
result, _ := Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:pending:sorted")
|
||||
removed = true
|
||||
}
|
||||
|
||||
// 3. Queue prioritaire
|
||||
result, _ = Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Supprimée de queue:priority:sorted")
|
||||
removed = true
|
||||
}
|
||||
|
||||
// 4. Vérifier toutes les autres queues de livreurs (au cas où)
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
result, _ := Redis.ZRem(RedisCtx, key, commandIDStr).Result()
|
||||
if result > 0 {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Commande trouvée et supprimée de %s", key)
|
||||
removed = true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Supprimer la clé de données
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
result, _ = Redis.Del(RedisCtx, commandKey).Result()
|
||||
if result > 0 {
|
||||
log.Printf("✅ [REMOVE_ALL] Données supprimées: %s", commandKey)
|
||||
removed = true
|
||||
}
|
||||
|
||||
if !removed {
|
||||
log.Printf("⚠️ [REMOVE_ALL] Commande %d n'a été trouvée dans aucune queue", commandID)
|
||||
} else {
|
||||
log.Printf("✅ [REMOVE_ALL] Commande %d supprimée de toutes les queues", commandID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// ============================================
|
||||
// db/cancel_sanctions_db.go
|
||||
// GESTION DES SANCTIONS ÉVOLUTIVES
|
||||
// ============================================
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
// GetClientCancellationsCount récupère le nombre d'annulations tardives d'un client
|
||||
func (d *Database) GetClientCancellationsCount(username string) (int, error) {
|
||||
var count int
|
||||
query := `SELECT COALESCE(cancellations_count, 0) FROM clients WHERE username = $1`
|
||||
|
||||
err := d.QueryRow(query, username).Scan(&count)
|
||||
if err != nil {
|
||||
log.Printf("❌ [GetCancellationsCount] Erreur: %v", err)
|
||||
return 0, fmt.Errorf("erreur récupération compteur: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// IncrementClientCancellationsCount incrémente le compteur d'annulations
|
||||
func (d *Database) IncrementClientCancellationsCount(username string) error {
|
||||
query := `UPDATE clients
|
||||
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $1`
|
||||
|
||||
result, err := d.Exec(query, username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [IncrementCancellations] Erreur: %v", err)
|
||||
return fmt.Errorf("erreur incrémentation: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [IncrementCancellations] Compteur incrémenté pour %s", username)
|
||||
|
||||
// Invalider le cache Redis
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CalculateCancellationPenalty calcule la pénalité selon l'historique
|
||||
// 1ère fois: 20 points
|
||||
// 2ème fois: 50 points
|
||||
// 3ème fois: 100 points
|
||||
// 4ème+ fois: 150 points
|
||||
func (d *Database) CalculateCancellationPenalty(username string) (int, error) {
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var penalty int
|
||||
switch {
|
||||
case count == 0:
|
||||
penalty = 20 // Première annulation tardive
|
||||
case count == 1:
|
||||
penalty = 50 // Deuxième annulation tardive
|
||||
case count == 2:
|
||||
penalty = 100 // Troisième annulation tardive
|
||||
default:
|
||||
penalty = 150 // À partir de la 4ème annulation
|
||||
}
|
||||
|
||||
log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points",
|
||||
username, count, penalty)
|
||||
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// ApplyCancellationPenalty applique une pénalité, incrémente le compteur et REMET TOUS LES POINTS À ZÉRO
|
||||
// ✅ MODIFIÉ: Récupère les points AVANT de les remettre à zéro pour le log dans le handler
|
||||
func (d *Database) ApplyCancellationPenalty(username string) (int, error) {
|
||||
// ✅ ÉTAPE 0: Récupérer les points AVANT modification (pour le handler)
|
||||
// Note: Le handler récupère aussi les points, mais on garde cette fonction autonome
|
||||
|
||||
// Calculer la pénalité AVANT d'incrémenter
|
||||
penalty, err := d.CalculateCancellationPenalty(username)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty)
|
||||
|
||||
// ✅ ÉTAPE 1: Incrémenter le compteur d'annulations
|
||||
if err := d.IncrementClientCancellationsCount(username); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// ✅ ÉTAPE 2: Mettre l'amende au montant de la pénalité ET remettre TOUS les points à zéro
|
||||
query := `UPDATE clients
|
||||
SET amende = $1,
|
||||
point = 0,
|
||||
point_zipette = 0,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE username = $2`
|
||||
|
||||
result, err := d.Exec(query, float64(penalty), username)
|
||||
if err != nil {
|
||||
log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", err)
|
||||
return 0, fmt.Errorf("erreur application pénalité: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur vérification: %w", err)
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
return 0, fmt.Errorf("client non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ [ApplyCancellationPenalty] %d points d'amende appliqués à %s + TOUS points remis à 0 (weed + zipette)", penalty, username)
|
||||
|
||||
// Invalider le cache Redis
|
||||
cacheKey := fmt.Sprintf("client:%s", username)
|
||||
Redis.Del(RedisCtx, cacheKey)
|
||||
|
||||
return penalty, nil
|
||||
}
|
||||
|
||||
// GetClientCancellationHistory récupère l'historique d'annulations d'un client
|
||||
func (d *Database) GetClientCancellationHistory(username string) (map[string]interface{}, error) {
|
||||
count, err := d.GetClientCancellationsCount(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculer la prochaine pénalité
|
||||
var nextPenalty int
|
||||
switch count {
|
||||
case 0:
|
||||
nextPenalty = 20
|
||||
case 1:
|
||||
nextPenalty = 50
|
||||
case 2:
|
||||
nextPenalty = 100
|
||||
default:
|
||||
nextPenalty = 150
|
||||
}
|
||||
|
||||
// Récupérer l'amende actuelle
|
||||
client, err := d.GetClientByUsername(username)
|
||||
var currentAmende float64
|
||||
if err == nil {
|
||||
currentAmende = client.Amende
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"cancellations_count": count,
|
||||
"current_amende": currentAmende,
|
||||
"next_penalty": nextPenalty,
|
||||
"penalty_scale": map[string]int{
|
||||
"1st": 20,
|
||||
"2nd": 50,
|
||||
"3rd": 100,
|
||||
"4th+": 150,
|
||||
},
|
||||
"warning": "TOUS les points de fidélité (weed/hash ET zipette) seront remis à zéro lors de la prochaine annulation tardive",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) CreateUser(user *models.User) error {
|
||||
query := `INSERT INTO users (username, password, role, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id, created_at, updated_at`
|
||||
|
||||
var createdAt, updatedAt time.Time
|
||||
err := d.QueryRow(query, user.Username, user.Password, user.Role).Scan(
|
||||
&user.ID,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la création de l'utilisateur: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllUsers récupère tous les utilisateurs
|
||||
func (d *Database) GetAllUsers() ([]*models.User, error) {
|
||||
query := `SELECT id, username, password, role, created_at, updated_at
|
||||
FROM users ORDER BY created_at DESC`
|
||||
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*models.User
|
||||
for rows.Next() {
|
||||
user := &models.User{}
|
||||
var createdAt, updatedAt time.Time
|
||||
err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.Password,
|
||||
&user.Role,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan de l'utilisateur: %w", err)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllDeliveryMen() ([]*models.User, error) {
|
||||
query := `
|
||||
SELECT id, username, password, role
|
||||
FROM users
|
||||
WHERE role = 'livreur'
|
||||
`
|
||||
|
||||
rows, err := d.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*models.User
|
||||
|
||||
for rows.Next() {
|
||||
user := &models.User{}
|
||||
err := rows.Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.Password,
|
||||
&user.Role,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors du scan de l'utilisateur: %w", err)
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de l'itération des résultats: %w", err)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// UpdateUser met à jour un utilisateur existant
|
||||
func (d *Database) UpdateUser(user *models.User) error {
|
||||
query := `UPDATE users
|
||||
SET username = $1, password = $2, role = $3, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4`
|
||||
|
||||
result, err := d.Exec(query, user.Username, user.Password, user.Role, user.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la mise à jour de l'utilisateur: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteUser supprime un utilisateur
|
||||
func (d *Database) DeleteUser(id int) error {
|
||||
// Récupérer le rôle de l'utilisateur avant de le supprimer
|
||||
var role string
|
||||
err := d.QueryRow(`SELECT role FROM users WHERE id = $1`, id).Scan(&role)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
return fmt.Errorf("erreur lors de la récupération du rôle: %w", err)
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ : Supprimer tous les tokens de l'utilisateur
|
||||
_ = d.RevokeAllUserTokens(id, role)
|
||||
|
||||
query := `DELETE FROM users WHERE id = $1`
|
||||
|
||||
result, err := d.Exec(query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la suppression de l'utilisateur: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur lors de la vérification des lignes affectées: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
|
||||
log.Printf("✅ Utilisateur supprimé (ID: %d, Role: %s)", id, role)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserByID récupère un utilisateur par son ID
|
||||
func (d *Database) GetUserByID(id int) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
query := `SELECT id, username, password, role, created_at, updated_at
|
||||
FROM users WHERE id = $1`
|
||||
|
||||
var createdAt, updatedAt time.Time
|
||||
err := d.QueryRow(query, id).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.Password,
|
||||
&user.Role,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetUserByUsername(username string) (*models.User, error) {
|
||||
user := &models.User{}
|
||||
query := `SELECT id, username, password, role
|
||||
FROM users WHERE username = $1`
|
||||
|
||||
err := d.QueryRow(query, username).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.Password,
|
||||
&user.Role,
|
||||
)
|
||||
|
||||
// ✅ Vérifier sql.ErrNoRows et retourner une erreur
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("utilisateur non trouvé")
|
||||
}
|
||||
|
||||
// Autres erreurs
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur lors de la récupération de l'utilisateur: %w", err)
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
var (
|
||||
Redis *redis.Client
|
||||
RedisCtx = context.Background()
|
||||
)
|
||||
|
||||
type CommandWithDistance struct {
|
||||
CommandID int
|
||||
Address string
|
||||
Lat float64
|
||||
Lng float64
|
||||
Distance float64
|
||||
EstimatedETA int
|
||||
QueueItem models.CommandQueue
|
||||
}
|
||||
|
||||
const (
|
||||
// Temps moyen estimé par livraison (en minutes)
|
||||
AVG_DELIVERY_TIME = 15
|
||||
// Nombre maximum de commandes par livreur
|
||||
MAX_COMMANDS_PER_DELIVERYMAN = 10
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// INITIALISATION DE REDIS
|
||||
// ============================================
|
||||
|
||||
func InitRedis() {
|
||||
host := getEnv("REDIS_HOST", "redis")
|
||||
port := getEnv("REDIS_PORT", "6379")
|
||||
password := os.Getenv("REDIS_PASSWORD")
|
||||
|
||||
addr := host + ":" + port
|
||||
|
||||
Redis = redis.NewClient(&redis.Options{
|
||||
Addr: addr,
|
||||
Password: password,
|
||||
DB: 0,
|
||||
})
|
||||
|
||||
_, err := Redis.Ping(RedisCtx).Result()
|
||||
if err != nil {
|
||||
log.Fatalf("❌ Erreur connexion Redis: %v", err)
|
||||
}
|
||||
|
||||
log.Println("⚡ Redis connecté")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"math"
|
||||
)
|
||||
|
||||
// FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter
|
||||
// ✅ Respecte le statut BUSY (queue >= 10)
|
||||
func (d *Database) FindLeastLoadedDeliveryman() (string, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
var leastLoaded string
|
||||
minQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
|
||||
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
// ✅ Ignorer les livreurs offline
|
||||
if status.Status == "offline" {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ NOUVEAU: Vérifier si le livreur peut accepter des commandes
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
log.Printf("⏭️ [LEAST_LOADED] Skip %s: ne peut pas accepter", username)
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Priorité aux livreurs disponibles (bonus de -1000)
|
||||
effectiveQueueSize := queueSize
|
||||
if status.Status == "available" {
|
||||
effectiveQueueSize -= 1000
|
||||
}
|
||||
|
||||
if effectiveQueueSize < minQueueSize {
|
||||
minQueueSize = effectiveQueueSize
|
||||
leastLoaded = username
|
||||
}
|
||||
}
|
||||
|
||||
if leastLoaded == "" {
|
||||
return "", fmt.Errorf("aucun livreur disponible")
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", leastLoaded)
|
||||
actualQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
log.Printf("🎯 [LEAST_LOADED] %s sélectionné (%d/10 commandes)",
|
||||
leastLoaded, actualQueueSize)
|
||||
|
||||
return leastLoaded, nil
|
||||
}
|
||||
|
||||
// FindAvailableOrLeastLoadedDeliveryman trouve un livreur disponible ou le moins chargé
|
||||
// ✅ Respecte le statut BUSY
|
||||
func (d *Database) FindAvailableOrLeastLoadedDeliveryman() (string, string, int, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil || len(keys) == 0 {
|
||||
return "", "", 0, fmt.Errorf("aucun livreur trouvé")
|
||||
}
|
||||
|
||||
var bestDeliveryman string
|
||||
var bestStatus string
|
||||
bestQueueSize := int64(MAX_COMMANDS_PER_DELIVERYMAN + 1)
|
||||
|
||||
for _, key := range keys {
|
||||
username := key[len("delivery:status:"):]
|
||||
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ NOUVEAU: Vérifier si le livreur peut accepter
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Priorité: available > busy avec moins de commandes
|
||||
if status.Status == "available" && queueSize == 0 {
|
||||
return username, "available", 0, nil
|
||||
}
|
||||
|
||||
if queueSize < bestQueueSize {
|
||||
bestQueueSize = queueSize
|
||||
bestDeliveryman = username
|
||||
bestStatus = status.Status
|
||||
}
|
||||
}
|
||||
|
||||
if bestDeliveryman == "" {
|
||||
return "", "", 0, fmt.Errorf("tous les livreurs sont au maximum de leur capacité")
|
||||
}
|
||||
|
||||
return bestDeliveryman, bestStatus, int(bestQueueSize), nil
|
||||
}
|
||||
|
||||
// GetLeastLoadedDeliverymanForced retourne le livreur avec le moins de commandes (SANS limite)
|
||||
// ⚠️ À utiliser uniquement pour assignation forcée par admin
|
||||
func (d *Database) GetLeastLoadedDeliverymanForced() (string, int64, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
if err != nil || len(activeUsernames) == 0 {
|
||||
return "", 0, fmt.Errorf("aucun livreur actif")
|
||||
}
|
||||
|
||||
var leastLoaded string
|
||||
var minQueueSize int64 = math.MaxInt64
|
||||
|
||||
for _, username := range activeUsernames {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if queueSize < minQueueSize {
|
||||
minQueueSize = queueSize
|
||||
leastLoaded = username
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("⚠️ [FORCED] %s sélectionné (%d commandes - SANS LIMITE)",
|
||||
leastLoaded, minQueueSize)
|
||||
|
||||
return leastLoaded, minQueueSize, nil
|
||||
}
|
||||
|
||||
// AreAllDeliverymenAtCapacity vérifie si tous les livreurs ont atteint leur capacité max
|
||||
func (d *Database) AreAllDeliverymenAtCapacity() (bool, int, error) {
|
||||
activeUsernames, err := d.GetAllActiveDeliverymenUsernames()
|
||||
if err != nil || len(activeUsernames) == 0 {
|
||||
return false, 0, fmt.Errorf("aucun livreur actif")
|
||||
}
|
||||
|
||||
// Si un seul livreur, jamais à capacité max
|
||||
if len(activeUsernames) == 1 {
|
||||
return false, 1, nil
|
||||
}
|
||||
|
||||
atCapacityCount := 0
|
||||
for _, username := range activeUsernames {
|
||||
if !d.CanDeliverymanAcceptCommands(username) {
|
||||
atCapacityCount++
|
||||
}
|
||||
}
|
||||
|
||||
allAtCapacity := atCapacityCount == len(activeUsernames)
|
||||
|
||||
if allAtCapacity {
|
||||
log.Printf("🔴 [CAPACITY] TOUS les livreurs sont à capacité maximale (%d/%d)",
|
||||
atCapacityCount, len(activeUsernames))
|
||||
}
|
||||
|
||||
return allAtCapacity, len(activeUsernames), nil
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (d *Database) UpdateDeliveryPersonLocation(username string, lat, lon float64) error {
|
||||
// 1️⃣ Mettre à jour la position GPS dans Redis
|
||||
key := fmt.Sprintf("delivery:location:%s", username)
|
||||
location := map[string]interface{}{
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"last_update": time.Now().Unix(),
|
||||
}
|
||||
data, _ := json.Marshal(location)
|
||||
err := Redis.Set(RedisCtx, key, data, 1*time.Hour).Err()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur mise à jour position: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📍 Position GPS mise à jour pour %s: (%.6f, %.6f)", username, lat, lon)
|
||||
|
||||
// 2️⃣ ✅ NOUVEAU: Auto-initialiser/synchroniser le statut
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", username)
|
||||
statusData, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
|
||||
if err != nil || statusData == "" {
|
||||
// ✅ Pas de statut → Créer "available" par défaut
|
||||
log.Printf("🆕 [INIT_STATUS] Création statut 'available' pour %s (première position GPS)", username)
|
||||
d.SetDeliveryPersonStatus(username, "available", 0)
|
||||
} else {
|
||||
// ✅ Statut existe → Vérifier s'il faut le réactiver
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(statusData), &status)
|
||||
|
||||
if status.Status == "offline" {
|
||||
// Si le livreur était offline et envoie sa position → Le remettre available
|
||||
log.Printf("🔄 [REACTIVATE] %s passe de 'offline' à 'available' (position GPS reçue)", username)
|
||||
d.SetDeliveryPersonStatus(username, "available", 0)
|
||||
} else {
|
||||
// ✅ Statut actif → Synchroniser basé sur la queue
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(username)
|
||||
}
|
||||
}
|
||||
|
||||
// 3️⃣ Publier l'événement de mise à jour de position
|
||||
d.PublishDeliveryPersonLocationUpdate(username, lat, lon)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDeliveryPersonLocation récupère la position d'un livreur
|
||||
func (d *Database) GetDeliveryPersonLocation(username string) (float64, float64, error) {
|
||||
key := fmt.Sprintf("delivery:location:%s", username)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("position non trouvée pour %s: %w", username, err)
|
||||
}
|
||||
|
||||
if data == "" {
|
||||
return 0, 0, fmt.Errorf("aucune donnée de position pour %s", username)
|
||||
}
|
||||
|
||||
var location map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(data), &location); err != nil {
|
||||
return 0, 0, fmt.Errorf("erreur parsing JSON Redis: %w", err)
|
||||
}
|
||||
|
||||
// Extraire latitude avec gestion de type robuste
|
||||
var lat, lon float64
|
||||
if v, ok := location["latitude"]; ok && v != nil {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
lat = val
|
||||
case float32:
|
||||
lat = float64(val)
|
||||
case int:
|
||||
lat = float64(val)
|
||||
case int64:
|
||||
lat = float64(val)
|
||||
case string:
|
||||
lat, _ = strconv.ParseFloat(val, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// Extraire longitude avec gestion de type robuste
|
||||
if v, ok := location["longitude"]; ok && v != nil {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
lon = val
|
||||
case float32:
|
||||
lon = float64(val)
|
||||
case int:
|
||||
lon = float64(val)
|
||||
case int64:
|
||||
lon = float64(val)
|
||||
case string:
|
||||
lon, _ = strconv.ParseFloat(val, 64)
|
||||
}
|
||||
}
|
||||
|
||||
if lat == 0 && lon == 0 {
|
||||
return 0, 0, fmt.Errorf("coordonnées invalides (0,0) pour %s", username)
|
||||
}
|
||||
|
||||
return lat, lon, nil
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func (d *Database) SetCommandETA(commandID, minutes int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(minutes) * time.Minute)
|
||||
|
||||
eta := map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": minutes,
|
||||
"eta_minutes": minutes,
|
||||
"wait_time_minutes": 0,
|
||||
"travel_time_minutes": minutes,
|
||||
"queue_position": 1,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
_, err := Redis.HSet(RedisCtx, key, eta).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
|
||||
}
|
||||
|
||||
Redis.Expire(RedisCtx, key, 2*time.Hour)
|
||||
|
||||
d.ScheduleETANotifications(commandID, minutes)
|
||||
|
||||
log.Printf("✅ ETA défini pour commande %d: %d minutes (arrivée: %s)",
|
||||
commandID, minutes, arrivalTime.Format("15:04"))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommandETA récupère l'ETA d'une commande depuis Redis
|
||||
func (d *Database) GetCommandETA(commandID int) (map[string]string, error) {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
etaData, err := Redis.HGetAll(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(etaData) == 0 {
|
||||
return nil, fmt.Errorf("aucun ETA trouvé pour la commande %d", commandID)
|
||||
}
|
||||
|
||||
return etaData, nil
|
||||
}
|
||||
|
||||
// CheckCommandETAExists vérifie si un ETA existe pour une commande
|
||||
func (d *Database) CheckCommandETAExists(commandID int) bool {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
exists, _ := Redis.Exists(RedisCtx, key).Result()
|
||||
return exists > 0
|
||||
}
|
||||
|
||||
// ScheduleETANotifications programme les notifications 5min et 3min
|
||||
func (d *Database) ScheduleETANotifications(commandID, etaMinutes int) error {
|
||||
arrivalTime := time.Now().Add(time.Duration(etaMinutes) * time.Minute)
|
||||
|
||||
if etaMinutes > 5 {
|
||||
notify5min := arrivalTime.Add(-5 * time.Minute).Unix()
|
||||
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
|
||||
Score: float64(notify5min),
|
||||
Member: fmt.Sprintf("%d:5min", commandID),
|
||||
})
|
||||
}
|
||||
|
||||
if etaMinutes > 3 {
|
||||
notify3min := arrivalTime.Add(-3 * time.Minute).Unix()
|
||||
Redis.ZAdd(RedisCtx, "notifications:scheduled", redis.Z{
|
||||
Score: float64(notify3min),
|
||||
Member: fmt.Sprintf("%d:3min", commandID),
|
||||
})
|
||||
}
|
||||
|
||||
log.Printf("✅ Notifications programmées pour commande %d (ETA: %d min)", commandID, etaMinutes)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessScheduledNotifications traite les notifications à envoyer
|
||||
func (d *Database) ProcessScheduledNotifications() error {
|
||||
now := float64(time.Now().Unix())
|
||||
|
||||
results, err := Redis.ZRangeByScore(RedisCtx, "notifications:scheduled", &redis.ZRangeBy{
|
||||
Min: "0",
|
||||
Max: strconv.FormatFloat(now, 'f', 0, 64),
|
||||
}).Result()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
parts := splitNotificationKey(result)
|
||||
commandID, _ := strconv.Atoi(parts[0])
|
||||
notifType := parts[1]
|
||||
|
||||
d.SendETANotification(commandID, notifType)
|
||||
Redis.ZRem(RedisCtx, "notifications:scheduled", result)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendETANotification envoie une notification ETA
|
||||
func (d *Database) SendETANotification(commandID int, notifType string) {
|
||||
message := fmt.Sprintf("Votre commande #%d arrive dans %s", commandID, notifType)
|
||||
|
||||
channel := fmt.Sprintf("notifications:command:%d", commandID)
|
||||
Redis.Publish(RedisCtx, channel, message)
|
||||
|
||||
log.Printf("📢 Notification envoyée: %s", message)
|
||||
}
|
||||
|
||||
// CalculateETAForDeliveryman calcule l'ETA entre un livreur et une destination
|
||||
func (d *Database) CalculateETAForDeliveryman(deliveryman string, destLat, destLng float64) int {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non trouvée, utilisation ETA par défaut", deliveryman)
|
||||
return services.MinETA
|
||||
}
|
||||
|
||||
from := services.Coordinates{
|
||||
Latitude: livreurLat,
|
||||
Longitude: livreurLng,
|
||||
}
|
||||
to := services.Coordinates{
|
||||
Latitude: destLat,
|
||||
Longitude: destLng,
|
||||
}
|
||||
|
||||
distance := services.CalculateDistance(from, to)
|
||||
eta := services.CalculateETA(distance)
|
||||
|
||||
log.Printf("📍 ETA calculé pour %s: %.2f km -> %d min", deliveryman, distance, eta)
|
||||
|
||||
return eta
|
||||
}
|
||||
|
||||
// CalculateDistanceBetweenPoints calcule la distance entre deux points
|
||||
func (d *Database) CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2 float64) float64 {
|
||||
from := services.Coordinates{Latitude: lat1, Longitude: lng1}
|
||||
to := services.Coordinates{Latitude: lat2, Longitude: lng2}
|
||||
return services.CalculateDistance(from, to)
|
||||
}
|
||||
|
||||
// CalculateETABetweenPoints calcule l'ETA entre deux points
|
||||
func (d *Database) CalculateETABetweenPoints(lat1, lng1, lat2, lng2 float64) int {
|
||||
distance := d.CalculateDistanceBetweenPoints(lat1, lng1, lat2, lng2)
|
||||
return services.CalculateETA(distance)
|
||||
}
|
||||
|
||||
func (d *Database) GetDeliverymanQueueStats(deliveryman string) (map[string]interface{}, error) {
|
||||
return d.GetDeliverymanQueueInfo(deliveryman)
|
||||
}
|
||||
|
||||
func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition int) error {
|
||||
key := fmt.Sprintf("command:eta:%d", commandID)
|
||||
|
||||
now := time.Now()
|
||||
arrivalTime := now.Add(time.Duration(totalETA) * time.Minute)
|
||||
|
||||
eta := map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"total_eta_minutes": totalETA,
|
||||
"queue_position": queuePosition,
|
||||
"updated_at": now.Unix(),
|
||||
"arrival_time": arrivalTime.Unix(),
|
||||
"estimated_arrival": arrivalTime.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
_, err := Redis.HSet(RedisCtx, key, eta).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sauvegarde ETA: %w", err)
|
||||
}
|
||||
|
||||
Redis.Expire(RedisCtx, key, 4*time.Hour)
|
||||
|
||||
log.Printf("✅ ETA détaillé pour commande %d: Total=%dmin Position=%d",
|
||||
commandID, totalETA, queuePosition)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package db
|
||||
|
||||
import "fmt"
|
||||
|
||||
func extractCommandID(member interface{}) int {
|
||||
switch v := member.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case string:
|
||||
var id int
|
||||
fmt.Sscanf(v, "%d", &id)
|
||||
return id
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func splitNotificationKey(key string) []string {
|
||||
for i, c := range key {
|
||||
if c == ':' {
|
||||
return []string{key[:i], key[i+1:]}
|
||||
}
|
||||
}
|
||||
return []string{key, ""}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
// db/redis_position.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// GESTION DES POSITIONS GPS
|
||||
// ============================================
|
||||
// ============================================
|
||||
// LEGACY: UpdateLivreurPosition (compatibilité)
|
||||
// ============================================
|
||||
|
||||
// UpdateLivreurPosition met à jour la position GPS ET le statut du livreur (LEGACY)
|
||||
func (d *Database) UpdateLivreurPosition(authUsername string, lat, lon float64, status string) error {
|
||||
// 🔹 1️⃣ Validation du username côté serveur
|
||||
if authUsername == "" {
|
||||
return fmt.Errorf("username non fourni ou non authentifié")
|
||||
}
|
||||
|
||||
// 🔹 2️⃣ Validation des coordonnées GPS
|
||||
if !isValidCoordinates(lat, lon) {
|
||||
return fmt.Errorf("coordonnées GPS invalides")
|
||||
}
|
||||
|
||||
// 🔹 3️⃣ Mise à jour position GPS (Redis)
|
||||
positionKey := fmt.Sprintf("livreur:position:%s", authUsername)
|
||||
position := models.LivreurPosition{
|
||||
Latitude: lat,
|
||||
Longitude: lon,
|
||||
UpdatedAt: time.Now(),
|
||||
Status: status,
|
||||
}
|
||||
|
||||
positionData, err := json.Marshal(position)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation position: %w", err)
|
||||
}
|
||||
|
||||
if err := Redis.Set(RedisCtx, positionKey, positionData, 2*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur mise à jour position: %w", err)
|
||||
}
|
||||
|
||||
// 🔹 4️⃣ Mise à jour statut sécurisé
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", authUsername)
|
||||
var deliveryStatus models.DeliveryPersonStatus
|
||||
|
||||
existingData, _ := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if existingData != "" {
|
||||
json.Unmarshal([]byte(existingData), &deliveryStatus)
|
||||
deliveryStatus.Status = status
|
||||
deliveryStatus.LastUpdate = time.Now()
|
||||
} else {
|
||||
deliveryStatus = models.DeliveryPersonStatus{
|
||||
Username: authUsername,
|
||||
Status: status,
|
||||
CurrentCommand: 0,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
statusData, err := json.Marshal(deliveryStatus)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation statut: %w", err)
|
||||
}
|
||||
|
||||
if err := Redis.Set(RedisCtx, statusKey, statusData, 24*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", err)
|
||||
}
|
||||
|
||||
// 🔹 5️⃣ Logs anonymisés (troncature)
|
||||
log.Printf("📍 Position GPS mise à jour pour %s: (lat: %.4f, lon: %.4f)", authUsername, truncate(lat), truncate(lon))
|
||||
log.Printf("✅ Statut mis à jour pour %s: %s", authUsername, status)
|
||||
|
||||
// 🔹 6️⃣ Publication événement sécurisée (à sécuriser côté subscriber)
|
||||
d.PublishDeliveryPersonLocationUpdate(authUsername, lat, lon)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLivreurPosition récupère la position d'un livreur depuis Redis (LEGACY)
|
||||
func (d *Database) GetLivreurPosition(authUsername string) (*models.LivreurPosition, error) {
|
||||
if authUsername == "" {
|
||||
return nil, fmt.Errorf("username non fourni ou non authentifié")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("livreur:position:%s", authUsername)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("position non trouvée: %w", err)
|
||||
}
|
||||
|
||||
var position models.LivreurPosition
|
||||
if err := json.Unmarshal([]byte(data), &position); err != nil {
|
||||
return nil, fmt.Errorf("erreur parsing position: %w", err)
|
||||
}
|
||||
|
||||
return &position, nil
|
||||
}
|
||||
|
||||
// Vérifie si la latitude et longitude sont valides
|
||||
func isValidCoordinates(lat, lon float64) bool {
|
||||
return !math.IsNaN(lat) && !math.IsNaN(lon) &&
|
||||
lat >= -90 && lat <= 90 &&
|
||||
lon >= -180 && lon <= 180
|
||||
}
|
||||
|
||||
// Tronque les coordonnées pour logs (4 décimales suffisent pour anonymiser)
|
||||
func truncate(f float64) float64 {
|
||||
return math.Round(f*10000) / 10000
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PublishCommandEvent publie un événement de commande
|
||||
func (d *Database) PublishCommandEvent(commandID int, eventType, message string) {
|
||||
channel := fmt.Sprintf("events:command:%d", commandID)
|
||||
|
||||
event := map[string]interface{}{
|
||||
"command_id": commandID,
|
||||
"type": eventType,
|
||||
"message": message,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(event)
|
||||
Redis.Publish(RedisCtx, channel, data)
|
||||
}
|
||||
|
||||
// PublishDeliveryPersonLocationUpdate publie un événement de mise à jour de position
|
||||
func (d *Database) PublishDeliveryPersonLocationUpdate(username string, lat, lon float64) {
|
||||
message := map[string]interface{}{
|
||||
"type": "location_update",
|
||||
"username": username,
|
||||
"latitude": lat,
|
||||
"longitude": lon,
|
||||
"timestamp": time.Now().Unix(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(message)
|
||||
Redis.Publish(RedisCtx, "delivery:events", data)
|
||||
|
||||
log.Printf("📡 Événement publié: Position de %s mise à jour", username)
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AssignCommandToDeliverymanQueue assigne une commande à la queue d'un livreur
|
||||
func (d *Database) AssignCommandToDeliverymanQueue(commandID int, deliveryman string, estimatedTravelTime int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Si plusieurs livreurs, appliquer la limite de 10
|
||||
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
// ✅ MODIFIÉ: Position dans la queue pour info seulement
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
limitInfo := ""
|
||||
if activeCount > 1 {
|
||||
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
} else {
|
||||
limitInfo = " (sans limite - seul livreur)"
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "queued",
|
||||
fmt.Sprintf("Ajouté à la queue de %s (position: %d%s, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, limitInfo, totalETA),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Queue %s (pos: %d%s, ETA trajet: %d min)",
|
||||
commandID, deliveryman, currentQueueSize+1, limitInfo, totalETA)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueUnlimited assigne sans limite (pour un seul livreur)
|
||||
func (d *Database) AssignCommandToDeliverymanQueueUnlimited(deliveryman string, queueItem models.CommandQueue) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: Calculer le temps de trajet direct depuis la position du livreur
|
||||
travelTime := d.CalculateETAForDeliveryman(deliveryman, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
queueItem.EstimatedETA = travelTime
|
||||
|
||||
err := d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(queueItem.CommandID, "assigned")
|
||||
d.AssignDeliveryPerson(queueItem.CommandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(queueItem.CommandID, travelTime, int(currentQueueSize)+1)
|
||||
|
||||
d.AddCommandLog(queueItem.CommandID, "queued",
|
||||
fmt.Sprintf("Assigné au seul livreur actif %s (position: %d, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, travelTime),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Queue %s (SANS LIMITE - pos: %d, ETA trajet: %d min)",
|
||||
queueItem.CommandID, deliveryman, currentQueueSize+1, travelTime)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssignCommandToDeliverymanQueueWithCoords assigne une commande avec les coordonnées GPS
|
||||
func (d *Database) AssignCommandToDeliverymanQueueWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if activeCount > 1 && currentQueueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
return fmt.Errorf("livreur %s a atteint le maximum de commandes (%d)", deliveryman, MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
}
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var username string
|
||||
if u, ok := command["username"].(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: username,
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ✅ CORRECTION: Mettre à jour livreur_assign dans la DB
|
||||
// ============================================
|
||||
updateQuery := `UPDATE commandes
|
||||
SET livreur_assign = $1,
|
||||
status = 'assigned',
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`
|
||||
|
||||
_, err = d.Exec(updateQuery, deliveryman, commandID)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur mise à jour livreur_assign: %v", err)
|
||||
return fmt.Errorf("erreur mise à jour DB: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ DB mise à jour: livreur_assign=%s pour commande %d", deliveryman, commandID)
|
||||
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
// Sauvegarder dans le cache destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": lat,
|
||||
"lon": lng,
|
||||
})
|
||||
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
|
||||
|
||||
limitInfo := ""
|
||||
if activeCount > 1 {
|
||||
limitInfo = fmt.Sprintf("/%d", MAX_COMMANDS_PER_DELIVERYMAN)
|
||||
} else {
|
||||
limitInfo = " (sans limite - seul livreur)"
|
||||
}
|
||||
|
||||
d.AddCommandLog(commandID, "assigned",
|
||||
fmt.Sprintf("Assigné à %s (position: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d -> Livreur %s (pos: %d%s, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
commandID, deliveryman, currentQueueSize+1, limitInfo, totalETA, lat, lng)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceAssignCommandToDeliverymanWithCoords assigne une commande de force avec coordonnées
|
||||
// ForceAssignCommandToDeliverymanWithCoords assigne une commande de force avec coordonnées
|
||||
func (d *Database) ForceAssignCommandToDeliverymanWithCoords(commandID int, deliveryman string, estimatedTravelTime int, lat, lng float64, address string) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var username string
|
||||
if u, ok := command["username"].(string); ok {
|
||||
username = u
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: username,
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
// Sauvegarder dans le cache destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
coordsJSON, _ := json.Marshal(map[string]float64{
|
||||
"lat": lat,
|
||||
"lon": lng,
|
||||
})
|
||||
Redis.Set(RedisCtx, destCacheKey, coordsJSON, 4*time.Hour)
|
||||
|
||||
d.AddCommandLog(commandID, "force_queued",
|
||||
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
deliveryman, currentQueueSize+1, totalETA, lat, lng),
|
||||
"system")
|
||||
|
||||
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min, coords: %.4f,%.4f)",
|
||||
commandID, deliveryman, currentQueueSize+1, totalETA, lat, lng)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ForceAssignCommandToDeliveryman assigne une commande à un livreur SANS vérifier la limite de 10
|
||||
func (d *Database) ForceAssignCommandToDeliveryman(commandID int, deliveryman string, estimatedTravelTime int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
currentQueueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// ✅ MODIFIÉ: ETA = temps de trajet direct uniquement
|
||||
totalETA := estimatedTravelTime
|
||||
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: totalETA,
|
||||
}
|
||||
|
||||
err = d.AddToDeliverymanQueue(deliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue: %w", err)
|
||||
}
|
||||
|
||||
d.UpdateCommandStatus(commandID, "assigned")
|
||||
d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
d.SetCommandETAWithDetails(commandID, totalETA, int(currentQueueSize)+1)
|
||||
|
||||
d.AddCommandLog(commandID, "force_queued",
|
||||
fmt.Sprintf("Assignation forcée à %s (position: %d, ETA trajet: %d min)",
|
||||
deliveryman, currentQueueSize+1, totalETA),
|
||||
"system")
|
||||
|
||||
log.Printf("⚠️ FORCE: Commande %d -> Queue %s (pos: %d, ETA trajet: %d min)",
|
||||
commandID, deliveryman, currentQueueSize+1, totalETA)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoAssignCommand assigne automatiquement une commande au premier livreur disponible
|
||||
func (d *Database) AutoAssignCommand(commandID int) error {
|
||||
// Récupérer les livreurs disponibles
|
||||
available, err := d.GetAvailableDeliveryPersonsRedis()
|
||||
if err != nil || len(available) == 0 {
|
||||
return fmt.Errorf("aucun livreur disponible")
|
||||
}
|
||||
|
||||
// Prendre le premier livreur
|
||||
livreur := available[0]
|
||||
|
||||
// Assigner dans la DB principale
|
||||
err = d.AssignDeliveryPerson(commandID, livreur.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Mettre à jour le statut dans Redis
|
||||
d.SetDeliveryPersonStatus(livreur.Username, "busy", commandID)
|
||||
|
||||
// Retirer de la file d'attente
|
||||
d.RemoveCommandFromQueue(commandID)
|
||||
|
||||
// Définir l'ETA initial
|
||||
d.SetCommandETA(commandID, 30)
|
||||
|
||||
log.Printf("✅ Commande %d auto-assignée à %s", commandID, livreur.Username)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) ProcessNextCommandInQueue(deliveryman string) error {
|
||||
log.Printf("🔄 Traitement de la prochaine commande pour %s", deliveryman)
|
||||
|
||||
// Vérifier d'abord la queue spécifique du livreur
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
results, err := Redis.ZRangeWithScores(RedisCtx, queueKey, 0, 0).Result()
|
||||
|
||||
if err == nil && len(results) > 0 {
|
||||
// Une commande est dans sa queue - la traiter
|
||||
commandID := extractCommandID(results[0].Member)
|
||||
|
||||
// Mettre à jour le statut
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
|
||||
|
||||
log.Printf("✅ Livreur %s traite la commande %d de sa queue", deliveryman, commandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Si aucune commande dans sa queue, chercher dans la queue générale
|
||||
generalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
|
||||
|
||||
if err != nil || len(generalResults) == 0 {
|
||||
log.Printf("ℹ️ Aucune commande en attente pour %s", deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
commandID := extractCommandID(generalResults[0].Member)
|
||||
|
||||
// Assigner la commande
|
||||
err = d.AssignDeliveryPerson(commandID, deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("❌ Erreur assignation commande %d: %v", commandID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Retirer de la queue générale
|
||||
Redis.ZRem(RedisCtx, "queue:pending:sorted", strconv.Itoa(commandID))
|
||||
|
||||
// Mettre à jour le statut
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", commandID)
|
||||
|
||||
// Définir un ETA par défaut
|
||||
d.SetCommandETA(commandID, 30)
|
||||
|
||||
// Ajouter log
|
||||
d.AddCommandLog(commandID, "auto_assigned",
|
||||
fmt.Sprintf("Assigné automatiquement à %s depuis la queue générale", deliveryman),
|
||||
"system")
|
||||
|
||||
log.Printf("✅ Commande %d assignée automatiquement à %s (queue générale)", commandID, deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// db/redis_queue_cleanup.go
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// NETTOYAGE DES COMMANDES INVALIDES
|
||||
// ============================================
|
||||
|
||||
// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes
|
||||
func (d *Database) CleanupInvalidQueueCommands() (int, error) {
|
||||
log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...")
|
||||
|
||||
// Récupérer toutes les clés de commandes en attente
|
||||
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur récupération des clés: %w", err)
|
||||
}
|
||||
|
||||
removedCount := 0
|
||||
validCount := 0
|
||||
|
||||
for _, key := range keys {
|
||||
// Récupérer les données
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CLEANUP] Impossible de lire %s: %v", key, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
log.Printf("❌ [CLEANUP] JSON invalide pour %s - SUPPRESSION", key)
|
||||
d.removeInvalidCommand(key, queueItem.CommandID, "JSON invalide")
|
||||
removedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ VALIDATION DES CHAMPS REQUIS
|
||||
isValid := true
|
||||
reasons := []string{}
|
||||
|
||||
// 1. Vérifier Username
|
||||
if queueItem.Username == "" {
|
||||
isValid = false
|
||||
reasons = append(reasons, "username vide")
|
||||
}
|
||||
|
||||
// 2. Vérifier Address
|
||||
if queueItem.Address == "" {
|
||||
isValid = false
|
||||
reasons = append(reasons, "adresse vide")
|
||||
}
|
||||
|
||||
// 3. Vérifier Coordinates
|
||||
if queueItem.Lat == 0 || queueItem.Lng == 0 {
|
||||
isValid = false
|
||||
reasons = append(reasons, "coordonnées GPS manquantes")
|
||||
}
|
||||
|
||||
// 4. Vérifier CreatedAt
|
||||
if queueItem.CreatedAt.IsZero() || queueItem.CreatedAt.Year() == 1 {
|
||||
isValid = false
|
||||
reasons = append(reasons, "date de création invalide")
|
||||
}
|
||||
|
||||
// 5. Vérifier TotalPrice (optionnel mais recommandé)
|
||||
if queueItem.TotalPrice <= 0 {
|
||||
log.Printf("⚠️ [CLEANUP] Commande %d: prix suspect (%.2f)", queueItem.CommandID, queueItem.TotalPrice)
|
||||
}
|
||||
|
||||
// ❌ SUPPRIMER SI INVALIDE
|
||||
if !isValid {
|
||||
log.Printf("❌ [CLEANUP] Commande %d INVALIDE: %v - SUPPRESSION", queueItem.CommandID, reasons)
|
||||
d.removeInvalidCommand(key, queueItem.CommandID, fmt.Sprintf("%v", reasons))
|
||||
removedCount++
|
||||
} else {
|
||||
validCount++
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("✅ [CLEANUP] Terminé: %d commandes supprimées, %d commandes valides restantes", removedCount, validCount)
|
||||
return removedCount, nil
|
||||
}
|
||||
|
||||
// removeInvalidCommand supprime une commande invalide de toutes les queues
|
||||
func (d *Database) removeInvalidCommand(key string, commandID int, reason string) {
|
||||
commandIDStr := fmt.Sprintf("%d", commandID)
|
||||
|
||||
// 1. Supprimer la clé de données
|
||||
Redis.Del(RedisCtx, key)
|
||||
|
||||
// 2. Supprimer de la queue générale
|
||||
Redis.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
|
||||
Redis.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// 3. Supprimer des queues de livreurs
|
||||
livreurKeys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, queueKey := range livreurKeys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
Redis.ZRem(RedisCtx, queueKey, commandIDStr)
|
||||
}
|
||||
|
||||
// 4. Supprimer l'ETA si existe
|
||||
etaKey := fmt.Sprintf("command:eta:%d", commandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// 5. Supprimer le cache de destination
|
||||
destKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
Redis.Del(RedisCtx, destKey)
|
||||
|
||||
// 6. Logger dans la base de données
|
||||
d.AddCommandLog(commandID, "cleanup_removed",
|
||||
fmt.Sprintf("Commande supprimée de Redis: %s", reason),
|
||||
"system")
|
||||
|
||||
log.Printf("🗑️ [CLEANUP] Commande %d supprimée: %s", commandID, reason)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VALIDATION STRICTE AVANT AJOUT À LA QUEUE
|
||||
// ============================================
|
||||
|
||||
// ValidateCommandBeforeQueue valide qu'une commande a toutes les données requises
|
||||
func (d *Database) ValidateCommandBeforeQueue(commandID int) error {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
// ✅ VALIDATION STRICTE
|
||||
errors := []string{}
|
||||
|
||||
// 1. Username
|
||||
username, ok := command["username"].(string)
|
||||
if !ok || username == "" {
|
||||
errors = append(errors, "username manquant")
|
||||
}
|
||||
|
||||
// 2. Address
|
||||
address := ""
|
||||
if addr, ok := command["delivery_address"].(string); ok && addr != "" {
|
||||
address = addr
|
||||
} else if addr, ok := command["adresse"].(string); ok && addr != "" {
|
||||
address = addr
|
||||
}
|
||||
if address == "" {
|
||||
errors = append(errors, "adresse de livraison manquante")
|
||||
}
|
||||
|
||||
// 3. Coordinates
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
if lat == 0 || lng == 0 {
|
||||
errors = append(errors, "coordonnées GPS manquantes")
|
||||
}
|
||||
|
||||
// 4. Total Price
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
if totalPrice <= 0 {
|
||||
errors = append(errors, "prix total invalide")
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("validation échouée: %v", errors)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NETTOYAGE AUTOMATIQUE PÉRIODIQUE
|
||||
// ============================================
|
||||
|
||||
// StartQueueCleanupScheduler démarre un nettoyage automatique toutes les 5 minutes
|
||||
func (d *Database) StartQueueCleanupScheduler() {
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
log.Println("🔄 [CLEANUP] Scheduler de nettoyage démarré (toutes les 5 minutes)")
|
||||
|
||||
for range ticker.C {
|
||||
removed, err := d.CleanupInvalidQueueCommands()
|
||||
if err != nil {
|
||||
log.Printf("❌ [CLEANUP] Erreur: %v", err)
|
||||
} else if removed > 0 {
|
||||
log.Printf("🧹 [CLEANUP] %d commandes invalides supprimées", removed)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RAPPORT DE VALIDATION
|
||||
// ============================================
|
||||
|
||||
// GetQueueValidationReport génère un rapport de validation sans supprimer
|
||||
func (d *Database) GetQueueValidationReport() (map[string]interface{}, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "queue:pending:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report := map[string]interface{}{
|
||||
"total_commands": len(keys),
|
||||
"valid_commands": 0,
|
||||
"invalid_commands": 0,
|
||||
"invalid_details": []map[string]interface{}{},
|
||||
"validation_results": []string{},
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
report["invalid_commands"] = report["invalid_commands"].(int) + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Validation
|
||||
issues := []string{}
|
||||
if queueItem.Username == "" {
|
||||
issues = append(issues, "username vide")
|
||||
}
|
||||
if queueItem.Address == "" {
|
||||
issues = append(issues, "adresse vide")
|
||||
}
|
||||
if queueItem.Lat == 0 || queueItem.Lng == 0 {
|
||||
issues = append(issues, "GPS manquant")
|
||||
}
|
||||
if queueItem.CreatedAt.IsZero() {
|
||||
issues = append(issues, "date invalide")
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
report["invalid_commands"] = report["invalid_commands"].(int) + 1
|
||||
report["invalid_details"] = append(report["invalid_details"].([]map[string]interface{}), map[string]interface{}{
|
||||
"command_id": queueItem.CommandID,
|
||||
"issues": issues,
|
||||
"data": queueItem,
|
||||
})
|
||||
} else {
|
||||
report["valid_commands"] = report["valid_commands"].(int) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// GetDeliverymanQueueInfo récupère les infos de queue d'un livreur
|
||||
func (d *Database) GetDeliverymanQueueInfo(deliveryman string) (map[string]interface{}, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Nombre de commandes dans la queue
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// Récupérer toutes les commandes
|
||||
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
// Vérifier le nombre de livreurs actifs pour déterminer la limite
|
||||
activeCount, _ := d.CountActiveDeliverymen()
|
||||
|
||||
// Récupérer les détails de chaque commande
|
||||
var commands []map[string]interface{}
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Récupérer les données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
commands = append(commands, map[string]interface{}{
|
||||
"position": i + 1,
|
||||
"command_id": commandID,
|
||||
"address": queueItem.Address,
|
||||
"estimated_eta": queueItem.EstimatedETA,
|
||||
"created_at": queueItem.CreatedAt,
|
||||
"lat": queueItem.Lat,
|
||||
"lng": queueItem.Lng,
|
||||
})
|
||||
}
|
||||
|
||||
// Déterminer si le livreur peut accepter plus de commandes
|
||||
canAcceptMore := true
|
||||
if activeCount > 1 {
|
||||
// Plusieurs livreurs: limite de 10
|
||||
canAcceptMore = queueSize < MAX_COMMANDS_PER_DELIVERYMAN
|
||||
}
|
||||
// Si un seul livreur: pas de limite (canAcceptMore reste true)
|
||||
|
||||
return map[string]interface{}{
|
||||
"deliveryman": deliveryman,
|
||||
"queue_size": queueSize,
|
||||
"commands": commands,
|
||||
"can_accept_more": canAcceptMore,
|
||||
"max_commands": MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"active_deliverymen": activeCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAllQueuesOverview() (map[string]interface{}, error) {
|
||||
overview := make(map[string]interface{})
|
||||
|
||||
// Queue générale
|
||||
generalQueueSize, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
overview["general_queue"] = generalQueueSize
|
||||
|
||||
// Queues par livreur avec détails
|
||||
deliverymanQueues := make(map[string]interface{})
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
username := key[len("queue:deliveryman:"):]
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
|
||||
deliverymanQueues[username] = map[string]interface{}{
|
||||
"queue_size": queueSize,
|
||||
"can_accept_more": queueSize < MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"capacity": fmt.Sprintf("%d/%d", queueSize, MAX_COMMANDS_PER_DELIVERYMAN),
|
||||
}
|
||||
}
|
||||
|
||||
overview["deliveryman_queues"] = deliverymanQueues
|
||||
|
||||
// Total
|
||||
var totalPending int64 = generalQueueSize
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
size, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
totalPending += size
|
||||
}
|
||||
overview["total_pending"] = totalPending
|
||||
|
||||
return overview, nil
|
||||
}
|
||||
|
||||
// GetQueueStats - Statistiques détaillées
|
||||
func (d *Database) GetQueueStats() (map[string]interface{}, error) {
|
||||
normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result()
|
||||
priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result()
|
||||
|
||||
// Compter les commandes dans les queues des livreurs
|
||||
var deliverymanQueueCount int64
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
for _, key := range keys {
|
||||
if len(key) > 6 && key[len(key)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
count, _ := Redis.ZCard(RedisCtx, key).Result()
|
||||
deliverymanQueueCount += count
|
||||
}
|
||||
|
||||
// Calculer temps d'attente moyen
|
||||
var totalWaitTime int64
|
||||
var commandCount int64
|
||||
|
||||
normalResults, _ := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, -1).Result()
|
||||
for _, result := range normalResults {
|
||||
commandID := extractCommandID(result.Member)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, _ := Redis.Get(RedisCtx, key).Result()
|
||||
|
||||
var item models.CommandQueue
|
||||
if json.Unmarshal([]byte(data), &item) == nil {
|
||||
waitMinutes := int64(time.Since(item.CreatedAt).Minutes())
|
||||
totalWaitTime += waitMinutes
|
||||
commandCount++
|
||||
}
|
||||
}
|
||||
|
||||
avgWaitTime := 0
|
||||
if commandCount > 0 {
|
||||
avgWaitTime = int(totalWaitTime / commandCount)
|
||||
}
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total_pending": normalCount + priorityCount,
|
||||
"general_queue": normalCount,
|
||||
"priority_queue": priorityCount,
|
||||
"deliveryman_queues": deliverymanQueueCount,
|
||||
"avg_wait_time_min": avgWaitTime,
|
||||
"max_commands_per_driver": MAX_COMMANDS_PER_DELIVERYMAN,
|
||||
"avg_delivery_time": AVG_DELIVERY_TIME,
|
||||
"last_updated": time.Now().Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,747 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// 🆕 GESTION AUTOMATIQUE DU STATUT BUSY
|
||||
// ============================================
|
||||
|
||||
// UpdateDeliverymanStatusBasedOnQueue met à jour automatiquement le statut
|
||||
// ✅ Status = "busy" si queue >= 10
|
||||
// ✅ Status = "available" si queue < 10
|
||||
func (d *Database) UpdateDeliverymanStatusBasedOnQueue(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
queueSize, err := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur récupération taille queue: %w", err)
|
||||
}
|
||||
|
||||
// Récupérer le statut actuel
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
|
||||
data, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [STATUS] Livreur %s n'a pas de statut Redis", deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Déterminer le nouveau statut
|
||||
var newStatus string
|
||||
var currentCommand int
|
||||
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
// 🔴 BUSY car queue pleine (10 commandes ou plus)
|
||||
newStatus = "busy"
|
||||
currentCommand = 0
|
||||
log.Printf("🔴 [STATUS] %s -> BUSY (queue pleine: %d/10)", deliveryman, queueSize)
|
||||
} else {
|
||||
// 🟢 AVAILABLE tant que queue < 10
|
||||
// Exception: si le livreur est en train de livrer (delivering), on garde ce statut
|
||||
if status.Status == "delivering" && status.CurrentCommand > 0 {
|
||||
newStatus = "delivering"
|
||||
currentCommand = status.CurrentCommand
|
||||
log.Printf("🟡 [STATUS] %s -> DELIVERING (queue: %d/10, livraison en cours: cmd %d)",
|
||||
deliveryman, queueSize, currentCommand)
|
||||
} else {
|
||||
newStatus = "available"
|
||||
currentCommand = 0
|
||||
log.Printf("🟢 [STATUS] %s -> AVAILABLE (queue: %d/10)", deliveryman, queueSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Mettre à jour le statut
|
||||
return d.SetDeliveryPersonStatus(deliveryman, newStatus, currentCommand)
|
||||
}
|
||||
|
||||
// CanDeliverymanAcceptCommands vérifie si un livreur peut accepter de nouvelles commandes
|
||||
// ✅ Retourne false si: status=busy ET queue>=10, ou status=offline
|
||||
func (d *Database) CanDeliverymanAcceptCommands(deliveryman string) bool {
|
||||
// 1. Vérifier le statut Redis
|
||||
statusKey := fmt.Sprintf("delivery:status:%s", deliveryman)
|
||||
data, err := Redis.Get(RedisCtx, statusKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [CHECK] Livreur %s sans statut Redis", deliveryman)
|
||||
return true // Fallback: autoriser si pas de statut
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. Si offline, refuser
|
||||
if status.Status == "offline" {
|
||||
log.Printf("⚫ [CHECK] %s REFUSÉ: offline", deliveryman)
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Vérifier la taille de la queue
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
// 4. Si queue >= 10, refuser
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
log.Printf("🔴 [CHECK] %s REFUSÉ: queue pleine (%d/10)", deliveryman, queueSize)
|
||||
return false
|
||||
}
|
||||
|
||||
log.Printf("🟢 [CHECK] %s AUTORISÉ (%d/10)", deliveryman, queueSize)
|
||||
return true
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsForAssignment récupère UNIQUEMENT les livreurs pouvant accepter
|
||||
func (d *Database) GetAvailableDeliveryPersonsForAssignment() ([]models.DeliveryPersonStatus, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var available []models.DeliveryPersonStatus
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ Vérifier si le livreur peut accepter des commandes
|
||||
if d.CanDeliverymanAcceptCommands(status.Username) {
|
||||
available = append(available, status)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("📊 [AVAILABLE] %d livreur(s) disponible(s) pour assignation", len(available))
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 FONCTIONS MODIFIÉES AVEC AUTO-STATUS
|
||||
// ============================================
|
||||
|
||||
// AddToDeliverymanQueue - VERSION MISE À JOUR avec auto-update du statut
|
||||
func (d *Database) AddToDeliverymanQueue(deliveryman string, queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation: %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder les données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
|
||||
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
|
||||
|
||||
// Ajouter à la queue sorted set du livreur (score = timestamp)
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
score := float64(time.Now().Unix())
|
||||
|
||||
err = Redis.ZAdd(RedisCtx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: queueItem.CommandID,
|
||||
}).Err()
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout queue Redis: %w", err)
|
||||
}
|
||||
|
||||
// Incrémenter le compteur de commandes en attente pour ce livreur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Incr(RedisCtx, counterKey)
|
||||
|
||||
log.Printf("✅ Commande %d ajoutée à la queue de %s", queueItem.CommandID, deliveryman)
|
||||
|
||||
// ✅ NOUVEAU: Mettre à jour automatiquement le statut
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddCommandToQueue ajoute une commande à la file d'attente Redis (version simple)
|
||||
func (d *Database) AddCommandToQueue(commandID int) error {
|
||||
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
|
||||
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
|
||||
return fmt.Errorf("validation échouée: %w", err)
|
||||
}
|
||||
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
// Gestion des coordonnées (NULL safe)
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer le total_prix avec gestion de type
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
// Récupérer l'adresse
|
||||
var address string
|
||||
if addr, ok := command["delivery_address"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: 0,
|
||||
}
|
||||
|
||||
// Ajouter à la queue générale
|
||||
return d.AddToGeneralQueue(queueItem)
|
||||
}
|
||||
|
||||
// AddCommandToSmartQueue - Ajoute une commande avec attribution au livreur le moins chargé
|
||||
func (d *Database) AddCommandToSmartQueue(commandID int, address string) error {
|
||||
if err := d.ValidateCommandBeforeQueue(commandID); err != nil {
|
||||
log.Printf("❌ [QUEUE] Commande %d REFUSÉE: %v", commandID, err)
|
||||
return fmt.Errorf("validation échouée: %w", err)
|
||||
}
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
// Gestion des coordonnées (NULL safe)
|
||||
var lat, lng float64
|
||||
if command["dest_latitude"] != nil {
|
||||
if latVal, ok := command["dest_latitude"].(float64); ok {
|
||||
lat = latVal
|
||||
}
|
||||
}
|
||||
if command["dest_longitude"] != nil {
|
||||
if lngVal, ok := command["dest_longitude"].(float64); ok {
|
||||
lng = lngVal
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer le total_prix avec gestion de type
|
||||
var totalPrice float64
|
||||
if tp, ok := command["total_prix"].(float64); ok {
|
||||
totalPrice = tp
|
||||
}
|
||||
|
||||
queueItem := models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Username: command["username"].(string),
|
||||
TotalPrice: totalPrice,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
CreatedAt: time.Now(),
|
||||
EstimatedETA: 0,
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: Utiliser FindLeastLoadedDeliveryman qui respecte maintenant le statut
|
||||
assignedDeliveryman, err := d.FindLeastLoadedDeliveryman()
|
||||
if err != nil {
|
||||
// Aucun livreur trouvé, ajouter à la queue générale
|
||||
log.Printf("⚠️ Aucun livreur trouvé, ajout à la queue générale")
|
||||
return d.AddToGeneralQueue(queueItem)
|
||||
}
|
||||
|
||||
// Ajouter la commande à la queue spécifique du livreur (auto-update du statut)
|
||||
err = d.AddToDeliverymanQueue(assignedDeliveryman, queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout à la queue du livreur: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📋 Commande %d assignée à la queue de %s", commandID, assignedDeliveryman)
|
||||
|
||||
// Publier l'événement
|
||||
d.PublishCommandEvent(commandID, "queued",
|
||||
fmt.Sprintf("En attente dans la queue de %s", assignedDeliveryman))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddToGeneralQueue ajoute une commande à la queue générale (fallback)
|
||||
func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error {
|
||||
data, err := json.Marshal(queueItem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur serialisation: %w", err)
|
||||
}
|
||||
|
||||
score := float64(time.Now().Unix())
|
||||
key := fmt.Sprintf("queue:pending:%d", queueItem.CommandID)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.Set(RedisCtx, key, data, 24*time.Hour)
|
||||
pipe.ZAdd(RedisCtx, "queue:pending:sorted", redis.Z{
|
||||
Score: score,
|
||||
Member: queueItem.CommandID,
|
||||
})
|
||||
|
||||
_, err = pipe.Exec(RedisCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur ajout queue générale: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("📥 Commande %d ajoutée à la queue générale", queueItem.CommandID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut
|
||||
func (d *Database) RemoveCommandFromQueue(commandID int) error {
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
|
||||
pipe := Redis.Pipeline()
|
||||
pipe.Del(RedisCtx, key)
|
||||
pipe.ZRem(RedisCtx, "queue:pending:sorted", commandIDStr)
|
||||
pipe.ZRem(RedisCtx, "queue:priority:sorted", commandIDStr)
|
||||
|
||||
// Trouver et retirer de la queue du livreur
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
var affectedDeliveryman string
|
||||
|
||||
for _, queueKey := range keys {
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Vérifier si la commande est dans cette queue
|
||||
_, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err == nil {
|
||||
// Commande trouvée dans cette queue
|
||||
affectedDeliveryman = queueKey[len("queue:deliveryman:"):]
|
||||
pipe.ZRem(RedisCtx, queueKey, commandIDStr)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", affectedDeliveryman)
|
||||
pipe.Decr(RedisCtx, counterKey)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
_, err := pipe.Exec(RedisCtx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur suppression: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ Commande %d retirée de la file", commandID)
|
||||
|
||||
// ✅ NOUVEAU: Mettre à jour le statut si un livreur était affecté
|
||||
if affectedDeliveryman != "" {
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(affectedDeliveryman)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) GetNextCommandInQueue() (*models.CommandQueue, error) {
|
||||
|
||||
normalResults, err := Redis.ZRangeWithScores(RedisCtx, "queue:pending:sorted", 0, 0).Result()
|
||||
|
||||
if err != nil || len(normalResults) == 0 {
|
||||
return nil, fmt.Errorf("aucune commande en attente")
|
||||
}
|
||||
|
||||
commandID := extractCommandID(normalResults[0].Member)
|
||||
if commandID <= 0 {
|
||||
return nil, fmt.Errorf("ID commande invalide")
|
||||
}
|
||||
|
||||
key := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("commande introuvable: %w", err)
|
||||
}
|
||||
|
||||
var queue models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queue); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
return &queue, nil
|
||||
}
|
||||
|
||||
// GetLastCommandInQueue récupère la dernière commande dans la queue d'un livreur
|
||||
func (d *Database) GetLastCommandInQueue(deliveryman string) (*models.CommandQueue, error) {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Récupérer la dernière commande (index -1)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, -1, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil, fmt.Errorf("queue vide")
|
||||
}
|
||||
|
||||
commandID := extractCommandID(commandIDs[0])
|
||||
if commandID <= 0 {
|
||||
return nil, fmt.Errorf("ID invalide")
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &queueItem, nil
|
||||
}
|
||||
|
||||
// GetCommandQueuePosition récupère la position d'une commande dans la queue
|
||||
func (d *Database) GetCommandQueuePosition(commandID int) (int, error) {
|
||||
commandIDStr := strconv.Itoa(commandID)
|
||||
|
||||
// Chercher d'abord dans les queues des livreurs
|
||||
keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result()
|
||||
|
||||
for _, queueKey := range keys {
|
||||
// Éviter les clés de compteur
|
||||
if len(queueKey) > 6 && queueKey[len(queueKey)-6:] == ":count" {
|
||||
continue
|
||||
}
|
||||
|
||||
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
|
||||
if err == nil {
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Chercher dans la queue générale
|
||||
rank, err := Redis.ZRank(RedisCtx, "queue:pending:sorted", commandIDStr).Result()
|
||||
if err == nil {
|
||||
return int(rank) + 1, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("commande non trouvée dans les queues")
|
||||
}
|
||||
|
||||
func (d *Database) ClearDeliverymanQueue(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Récupérer toutes les commandes
|
||||
commandIDs, _ := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
|
||||
// Redistribuer chaque commande
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Récupérer les données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Trouver un nouveau livreur
|
||||
newDeliveryman, err := d.FindLeastLoadedDeliveryman()
|
||||
if err != nil {
|
||||
// Fallback: queue générale
|
||||
d.AddToGeneralQueue(queueItem)
|
||||
continue
|
||||
}
|
||||
|
||||
// Réassigner à un autre livreur
|
||||
if newDeliveryman != deliveryman {
|
||||
d.AddToDeliverymanQueue(newDeliveryman, queueItem)
|
||||
log.Printf("🔄 Commande %d réassignée de %s à %s",
|
||||
commandID, deliveryman, newDeliveryman)
|
||||
}
|
||||
}
|
||||
|
||||
// Vider la queue
|
||||
Redis.Del(RedisCtx, queueKey)
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
|
||||
|
||||
log.Printf("🗑️ Queue de %s vidée et redistribuée", deliveryman)
|
||||
|
||||
// ✅ NOUVEAU: Mettre à jour le statut
|
||||
go d.UpdateDeliverymanStatusBasedOnQueue(deliveryman)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) SetDeliveryPersonStatus(username, status string, commandID int) error {
|
||||
key := fmt.Sprintf("delivery:status:%s", username)
|
||||
|
||||
statusData := models.DeliveryPersonStatus{
|
||||
Username: username,
|
||||
Status: status,
|
||||
CurrentCommand: commandID,
|
||||
LastUpdate: time.Now(),
|
||||
}
|
||||
|
||||
data, _ := json.Marshal(statusData)
|
||||
err := Redis.Set(RedisCtx, key, data, 24*time.Hour).Err()
|
||||
|
||||
if err == nil {
|
||||
log.Printf("✅ Statut livreur %s: %s", username, status)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAvailableDeliveryPersonsRedis récupère les livreurs disponibles (LEGACY)
|
||||
func (d *Database) GetAvailableDeliveryPersonsRedis() ([]models.DeliveryPersonStatus, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var available []models.DeliveryPersonStatus
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status == "available" {
|
||||
available = append(available, status)
|
||||
}
|
||||
}
|
||||
|
||||
return available, nil
|
||||
}
|
||||
|
||||
// GetAllActiveDeliveryPersons - VERSION MISE À JOUR avec vérification capacité
|
||||
func (d *Database) GetAllActiveDeliveryPersons() ([]models.DeliveryPersonStatus, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var active []models.DeliveryPersonStatus
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status != "offline" {
|
||||
// ✅ Vérifier si le livreur peut accepter des commandes
|
||||
if d.CanDeliverymanAcceptCommands(status.Username) {
|
||||
active = append(active, status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return active, nil
|
||||
}
|
||||
|
||||
// CountActiveDeliverymen compte le nombre de livreurs actifs (non offline)
|
||||
func (d *Database) CountActiveDeliverymen() (int, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status != "offline" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetSingleActiveDeliveryman() (string, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status != "offline" {
|
||||
return status.Username, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("aucun livreur actif trouvé")
|
||||
}
|
||||
|
||||
// GetAllActiveDeliverymenUsernames retourne les usernames de tous les livreurs actifs
|
||||
func (d *Database) GetAllActiveDeliverymenUsernames() ([]string, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var activeUsernames []string
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
json.Unmarshal([]byte(data), &status)
|
||||
|
||||
if status.Status != "offline" {
|
||||
activeUsernames = append(activeUsernames, status.Username)
|
||||
}
|
||||
}
|
||||
|
||||
return activeUsernames, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 SYNCHRONISATION DES STATUTS
|
||||
// ============================================
|
||||
|
||||
// SyncAllDeliverymanStatuses synchronise tous les statuts (à appeler au démarrage)
|
||||
func (d *Database) SyncAllDeliverymanStatuses() error {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Println("🔄 [SYNC] Synchronisation des statuts livreurs...")
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Mettre à jour le statut basé sur la queue
|
||||
d.UpdateDeliverymanStatusBasedOnQueue(status.Username)
|
||||
}
|
||||
|
||||
log.Println("✅ [SYNC] Synchronisation terminée")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 📊 RAPPORT DE CAPACITÉ
|
||||
// ============================================
|
||||
|
||||
// GetDeliverymanCapacityReport génère un rapport détaillé
|
||||
func (d *Database) GetDeliverymanCapacityReport() (map[string]interface{}, error) {
|
||||
keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
report := map[string]interface{}{
|
||||
"total_deliverymen": 0,
|
||||
"available": 0,
|
||||
"busy_full": 0, // BUSY car queue pleine
|
||||
"busy_delivering": 0, // BUSY car en livraison
|
||||
"offline": 0,
|
||||
"details": []map[string]interface{}{},
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
data, err := Redis.Get(RedisCtx, key).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var status models.DeliveryPersonStatus
|
||||
if err := json.Unmarshal([]byte(data), &status); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", status.Username)
|
||||
queueSize, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
canAccept := d.CanDeliverymanAcceptCommands(status.Username)
|
||||
|
||||
detail := map[string]interface{}{
|
||||
"username": status.Username,
|
||||
"status": status.Status,
|
||||
"queue_size": queueSize,
|
||||
"capacity": fmt.Sprintf("%d/10", queueSize),
|
||||
"can_accept": canAccept,
|
||||
"current_order": status.CurrentCommand,
|
||||
}
|
||||
|
||||
report["total_deliverymen"] = report["total_deliverymen"].(int) + 1
|
||||
|
||||
if status.Status == "offline" {
|
||||
report["offline"] = report["offline"].(int) + 1
|
||||
} else if status.Status == "busy" {
|
||||
if queueSize >= MAX_COMMANDS_PER_DELIVERYMAN {
|
||||
report["busy_full"] = report["busy_full"].(int) + 1
|
||||
} else {
|
||||
report["busy_delivering"] = report["busy_delivering"].(int) + 1
|
||||
}
|
||||
} else if canAccept {
|
||||
report["available"] = report["available"].(int) + 1
|
||||
}
|
||||
|
||||
report["details"] = append(report["details"].([]map[string]interface{}), detail)
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"gestion/services"
|
||||
"log"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// CompleteDeliveryAndProcessNext marque une livraison comme terminée et optimise la queue
|
||||
func (d *Database) CompleteDeliveryAndProcessNext(deliveryman string, completedCommandID int) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
// Retirer la commande complétée de la queue
|
||||
Redis.ZRem(RedisCtx, queueKey, strconv.Itoa(completedCommandID))
|
||||
|
||||
// Supprimer les données de la commande
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, commandKey)
|
||||
|
||||
// Supprimer le cache de destination
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, destCacheKey)
|
||||
|
||||
// Supprimer l'ETA
|
||||
etaKey := fmt.Sprintf("command:eta:%d", completedCommandID)
|
||||
Redis.Del(RedisCtx, etaKey)
|
||||
|
||||
// Décrémenter le compteur
|
||||
counterKey := fmt.Sprintf("queue:deliveryman:%s:count", deliveryman)
|
||||
Redis.Decr(RedisCtx, counterKey)
|
||||
|
||||
log.Printf("✅ Livraison %d complétée par %s", completedCommandID, deliveryman)
|
||||
|
||||
// Vérifier s'il reste des commandes
|
||||
remainingCount, _ := Redis.ZCard(RedisCtx, queueKey).Result()
|
||||
|
||||
if remainingCount == 0 {
|
||||
d.SetDeliveryPersonStatus(deliveryman, "available", 0)
|
||||
log.Printf("🔓 Livreur %s libéré - Plus de commandes en queue", deliveryman)
|
||||
|
||||
// Chercher dans la queue générale
|
||||
go d.ProcessNextCommandInQueue(deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 🔄 OPTIMISATION PAR PROXIMITÉ
|
||||
// ============================================
|
||||
log.Printf("🔄 Optimisation queue de %s: %d commande(s) restante(s)", deliveryman, remainingCount)
|
||||
|
||||
err := d.OptimizeDeliverymanQueueByProximity(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Erreur optimisation queue: %v", err)
|
||||
// Fallback: recalculer les ETAs sans réorganiser
|
||||
d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
// Récupérer la prochaine commande (maintenant la plus proche)
|
||||
nextCommand, nextETA, err := d.FindNearestCommandInQueue(deliveryman)
|
||||
if err == nil && nextCommand != nil {
|
||||
d.SetDeliveryPersonStatus(deliveryman, "busy", nextCommand.CommandID)
|
||||
log.Printf("📍 Prochaine livraison: Commande %d (ETA: %d min)", nextCommand.CommandID, nextETA)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) OptimizeDeliverymanQueueByProximity(deliveryman string) error {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non disponible, recalcul ETAs simple", deliveryman)
|
||||
return d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collecter les informations de chaque commande avec sa distance
|
||||
var commandsWithDistance []CommandWithDistance
|
||||
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Essayer de récupérer depuis queue:pending:{id}
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
|
||||
var lat, lng float64
|
||||
var address string
|
||||
|
||||
if err == nil && data != "" {
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err == nil {
|
||||
lat = queueItem.Lat
|
||||
lng = queueItem.Lng
|
||||
address = queueItem.Address
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Si coordonnées à 0, essayer le cache command:destination:{id}
|
||||
if lat == 0 && lng == 0 {
|
||||
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
|
||||
destData, err := Redis.Get(RedisCtx, destCacheKey).Result()
|
||||
if err == nil && destData != "" {
|
||||
var coords struct {
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(destData), &coords); err == nil {
|
||||
lat = coords.Lat
|
||||
lng = coords.Lon
|
||||
log.Printf("📍 Coordonnées récupérées depuis cache destination pour commande %d: (%.6f, %.6f)", commandID, lat, lng)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Si toujours 0, récupérer depuis la DB
|
||||
if lat == 0 && lng == 0 {
|
||||
command, err := d.GetCommandByID(commandID)
|
||||
if err == nil {
|
||||
if dLat, ok := command["dest_latitude"].(float64); ok && dLat != 0 {
|
||||
lat = dLat
|
||||
}
|
||||
if dLng, ok := command["dest_longitude"].(float64); ok && dLng != 0 {
|
||||
lng = dLng
|
||||
}
|
||||
if address == "" {
|
||||
if addr, ok := command["adresse"].(string); ok {
|
||||
address = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Si toujours 0, utiliser une distance très grande
|
||||
if lat == 0 && lng == 0 {
|
||||
log.Printf("⚠️ Coordonnées non disponibles pour commande %d, utilisation position par défaut", commandID)
|
||||
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: 0,
|
||||
Lng: 0,
|
||||
Distance: 999999,
|
||||
EstimatedETA: 120,
|
||||
QueueItem: models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: 0,
|
||||
Lng: 0,
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: Distance depuis la position ACTUELLE du livreur (pas chaînée)
|
||||
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, lat, lng)
|
||||
|
||||
commandsWithDistance = append(commandsWithDistance, CommandWithDistance{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
Distance: distance,
|
||||
EstimatedETA: services.CalculateETA(distance),
|
||||
QueueItem: models.CommandQueue{
|
||||
CommandID: commandID,
|
||||
Address: address,
|
||||
Lat: lat,
|
||||
Lng: lng,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if len(commandsWithDistance) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Trier par distance (la plus proche en premier)
|
||||
sort.Slice(commandsWithDistance, func(i, j int) bool {
|
||||
return commandsWithDistance[i].Distance < commandsWithDistance[j].Distance
|
||||
})
|
||||
|
||||
log.Printf("🔄 Optimisation queue de %s: %d commandes triées par proximité", deliveryman, len(commandsWithDistance))
|
||||
|
||||
// Vider la queue actuelle
|
||||
Redis.Del(RedisCtx, queueKey)
|
||||
Redis.Del(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman))
|
||||
|
||||
// ✅ MODIFIÉ: Recréer la queue avec ETA = distance directe depuis position livreur
|
||||
for i, cmd := range commandsWithDistance {
|
||||
var travelTime int
|
||||
var distance float64
|
||||
|
||||
if cmd.Lat != 0 && cmd.Lng != 0 {
|
||||
// ✅ Distance depuis la position ACTUELLE du livreur (pas cumulative)
|
||||
distance = d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, cmd.Lat, cmd.Lng)
|
||||
travelTime = services.CalculateETA(distance)
|
||||
} else {
|
||||
distance = 0
|
||||
travelTime = 10
|
||||
}
|
||||
|
||||
// ✅ ETA = temps de trajet direct uniquement
|
||||
cmd.QueueItem.EstimatedETA = travelTime
|
||||
|
||||
score := float64(i + 1)
|
||||
|
||||
data, _ := json.Marshal(cmd.QueueItem)
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", cmd.CommandID)
|
||||
Redis.Set(RedisCtx, commandKey, data, 24*time.Hour)
|
||||
|
||||
Redis.ZAdd(RedisCtx, queueKey, redis.Z{
|
||||
Score: score,
|
||||
Member: cmd.CommandID,
|
||||
})
|
||||
|
||||
d.SetCommandETAWithDetails(cmd.CommandID, travelTime, i+1)
|
||||
|
||||
log.Printf(" 📍 Position %d: Commande %d - %.2f km - ETA trajet: %d min",
|
||||
i+1, cmd.CommandID, distance, travelTime)
|
||||
}
|
||||
|
||||
Redis.Set(RedisCtx, fmt.Sprintf("queue:deliveryman:%s:count", deliveryman), len(commandsWithDistance), 0)
|
||||
|
||||
log.Printf("✅ Queue de %s optimisée: %d commandes réorganisées par proximité", deliveryman, len(commandsWithDistance))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) RecalculateQueueETAs(deliveryman string) error {
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ✅ Récupérer la position actuelle du livreur
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Position livreur %s non disponible pour recalcul ETA", deliveryman)
|
||||
return err
|
||||
}
|
||||
|
||||
for i, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// ✅ MODIFIÉ: ETA = distance directe depuis position livreur
|
||||
travelTime := d.CalculateETABetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
d.SetCommandETAWithDetails(commandID, travelTime, i+1)
|
||||
|
||||
queueItem.EstimatedETA = travelTime
|
||||
updatedData, _ := json.Marshal(queueItem)
|
||||
Redis.Set(RedisCtx, commandKey, updatedData, 24*time.Hour)
|
||||
}
|
||||
|
||||
log.Printf("🔄 ETAs recalculés pour %d commandes de %s (trajet direct)", len(commandIDs), deliveryman)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) UpdateQueueETAsAfterCompletion(deliveryman string) error {
|
||||
return d.RecalculateQueueETAs(deliveryman)
|
||||
}
|
||||
|
||||
// FindNearestCommandInQueue trouve la commande la plus proche du livreur
|
||||
func (d *Database) FindNearestCommandInQueue(deliveryman string) (*models.CommandQueue, int, error) {
|
||||
livreurLat, livreurLng, err := d.GetDeliveryPersonLocation(deliveryman)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("position livreur non disponible: %w", err)
|
||||
}
|
||||
|
||||
queueKey := fmt.Sprintf("queue:deliveryman:%s", deliveryman)
|
||||
commandIDs, err := Redis.ZRange(RedisCtx, queueKey, 0, -1).Result()
|
||||
if err != nil || len(commandIDs) == 0 {
|
||||
return nil, 0, fmt.Errorf("queue vide")
|
||||
}
|
||||
|
||||
var nearestCommand *models.CommandQueue
|
||||
var nearestDistance float64 = math.MaxFloat64
|
||||
var nearestETA int
|
||||
|
||||
for _, cmdIDStr := range commandIDs {
|
||||
commandID := extractCommandID(cmdIDStr)
|
||||
if commandID <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
commandKey := fmt.Sprintf("queue:pending:%d", commandID)
|
||||
data, err := Redis.Get(RedisCtx, commandKey).Result()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var queueItem models.CommandQueue
|
||||
if err := json.Unmarshal([]byte(data), &queueItem); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
distance := d.CalculateDistanceBetweenPoints(livreurLat, livreurLng, queueItem.Lat, queueItem.Lng)
|
||||
|
||||
if distance < nearestDistance {
|
||||
nearestDistance = distance
|
||||
nearestCommand = &queueItem
|
||||
nearestETA = services.CalculateETA(distance)
|
||||
}
|
||||
}
|
||||
|
||||
if nearestCommand == nil {
|
||||
return nil, 0, fmt.Errorf("aucune commande trouvée")
|
||||
}
|
||||
|
||||
return nearestCommand, nearestETA, nil
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// SESSION REDIS - GESTION UTILISATEUR
|
||||
// ============================================
|
||||
|
||||
// SessionData représente une session client en Redis
|
||||
type SessionData struct {
|
||||
ClientID int `json:"client_id"`
|
||||
Username string `json:"username"`
|
||||
SessionID string `json:"session_id"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
LastActivity int64 `json:"last_activity"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
BasketVersion int `json:"basket_version"`
|
||||
PointsCache int `json:"points_cache"`
|
||||
PenaltyCache float64 `json:"penalty_cache"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CRÉER UNE SESSION CLIENT
|
||||
// ============================================
|
||||
|
||||
// CreateClientSession crée une session Redis pour un client authentifié
|
||||
// Appelé depuis handlers/auth.go après LoginClient réussi
|
||||
//
|
||||
// Exemple d'utilisation:
|
||||
//
|
||||
// sessionID := uuid.New().String()
|
||||
// database.CreateClientSession(clientID, username, sessionID)
|
||||
func (d *Database) CreateClientSession(clientID int, username string, sessionID string) error {
|
||||
log.Printf("📝 [SESSION] Création session pour client: %s (ID: %d)", username, clientID)
|
||||
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
now := time.Now().Unix()
|
||||
expiresAt := now + (5 * 3600) // 5 heures
|
||||
|
||||
sessionData := SessionData{
|
||||
ClientID: clientID,
|
||||
Username: username,
|
||||
SessionID: sessionID,
|
||||
Role: "client",
|
||||
CreatedAt: now,
|
||||
LastActivity: now,
|
||||
ExpiresAt: expiresAt,
|
||||
BasketVersion: 0,
|
||||
PointsCache: 0,
|
||||
PenaltyCache: 0,
|
||||
}
|
||||
|
||||
// Sérialiser et sauvegarder
|
||||
sessionJSON, err := json.Marshal(sessionData)
|
||||
if err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur sérialisation: %v", err)
|
||||
return fmt.Errorf("erreur sérialisation session: %w", err)
|
||||
}
|
||||
|
||||
ttl := time.Duration(expiresAt-now) * time.Second
|
||||
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur sauvegarde Redis: %v", err)
|
||||
return fmt.Errorf("erreur sauvegarde session Redis: %w", err)
|
||||
}
|
||||
|
||||
// Ajouter à l'index des sessions actives
|
||||
if err := Redis.SAdd(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur ajout index: %v", err)
|
||||
}
|
||||
|
||||
// Charger les infos du client (points, penalty) dans le cache
|
||||
if client, err := d.GetClientByUsername(username); err == nil {
|
||||
sessionData.PointsCache = int(client.Point)
|
||||
sessionData.PenaltyCache = float64(client.Amende)
|
||||
sessionJSON, _ := json.Marshal(sessionData)
|
||||
Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl)
|
||||
}
|
||||
|
||||
log.Printf("✅ [SESSION] Session créée pour %s - TTL: 5h", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// RÉCUPÉRER UNE SESSION CLIENT
|
||||
// ============================================
|
||||
|
||||
// GetClientSession récupère la session Redis d'un client
|
||||
// Retourne nil si session expirée ou inexistante
|
||||
func (d *Database) GetClientSession(clientID int) (*SessionData, error) {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, sessionKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [SESSION] Pas de session trouvée pour client %d", clientID)
|
||||
return nil, fmt.Errorf("session non trouvée")
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal([]byte(data), &session); err != nil {
|
||||
log.Printf("❌ [SESSION] Erreur désérialisation: %v", err)
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Vérifier si session expirée
|
||||
if time.Now().Unix() > session.ExpiresAt {
|
||||
log.Printf("⚠️ [SESSION] Session expirée pour client %d", clientID)
|
||||
Redis.Del(RedisCtx, sessionKey)
|
||||
return nil, fmt.Errorf("session expirée")
|
||||
}
|
||||
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// METTRE À JOUR L'ACTIVITÉ DE SESSION
|
||||
// ============================================
|
||||
|
||||
// RefreshSessionTimeout prolonge la durée de vie de la session
|
||||
// Appelé régulièrement par SessionMiddleware (chaque requête client)
|
||||
func (d *Database) RefreshSessionTimeout(clientID int) error {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
|
||||
// Récupérer la session
|
||||
data, err := Redis.Get(RedisCtx, sessionKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("session non trouvée")
|
||||
}
|
||||
|
||||
var session SessionData
|
||||
if err := json.Unmarshal([]byte(data), &session); err != nil {
|
||||
return fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Mettre à jour lastActivity et expiresAt
|
||||
now := time.Now().Unix()
|
||||
session.LastActivity = now
|
||||
session.ExpiresAt = now + (5 * 3600) // Prolonger de 5 heures
|
||||
|
||||
// Resauvegarder
|
||||
sessionJSON, _ := json.Marshal(session)
|
||||
ttl := time.Duration(session.ExpiresAt-now) * time.Second
|
||||
|
||||
if err := Redis.Set(RedisCtx, sessionKey, sessionJSON, ttl).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur refresh: %v", err)
|
||||
return nil // Pas critique
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// INVALIDER UNE SESSION (LOGOUT)
|
||||
// ============================================
|
||||
|
||||
// InvalidateSession supprime la session Redis (logout)
|
||||
// Appelé depuis handlers/auth.go dans LogoutClient
|
||||
func (d *Database) InvalidateSession(clientID int) error {
|
||||
sessionKey := fmt.Sprintf("session:client:%d", clientID)
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Supprimer la session
|
||||
if err := Redis.Del(RedisCtx, sessionKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur suppression: %v", err)
|
||||
}
|
||||
|
||||
// Vider le panier Redis
|
||||
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur suppression panier: %v", err)
|
||||
}
|
||||
|
||||
// Retirer de l'index
|
||||
if err := Redis.SRem(RedisCtx, "session:active:clients", clientID).Err(); err != nil {
|
||||
log.Printf("⚠️ [SESSION] Erreur retrait index: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [SESSION] Session invalidée pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PANIER EN CACHE REDIS
|
||||
// ============================================
|
||||
|
||||
// BasketItemCache représente un item du panier en cache
|
||||
type BasketItemCache struct {
|
||||
ID int `json:"id"`
|
||||
ProductID int `json:"product_id"`
|
||||
ProductName string `json:"product_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
Price float64 `json:"price"`
|
||||
Category string `json:"category"`
|
||||
AddedAt int64 `json:"added_at"`
|
||||
}
|
||||
|
||||
// GetSessionBasket récupère le panier en cache Redis
|
||||
// Retourne les items du panier avec total
|
||||
func (d *Database) GetSessionBasket(clientID int) ([]BasketItemCache, float64, error) {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Récupérer tous les items du panier
|
||||
items, err := Redis.HGetAll(RedisCtx, basketKey).Result()
|
||||
if err != nil {
|
||||
log.Printf("⚠️ [BASKET] Pas de panier en cache pour client %d", clientID)
|
||||
return []BasketItemCache{}, 0, nil
|
||||
}
|
||||
|
||||
var basketItems []BasketItemCache
|
||||
var totalPrice float64
|
||||
|
||||
for _, itemJSON := range items {
|
||||
var item BasketItemCache
|
||||
if err := json.Unmarshal([]byte(itemJSON), &item); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur parsing item: %v", err)
|
||||
continue
|
||||
}
|
||||
basketItems = append(basketItems, item)
|
||||
totalPrice += item.Price * float64(item.Quantity)
|
||||
}
|
||||
|
||||
return basketItems, totalPrice, nil
|
||||
}
|
||||
|
||||
// UpdateSessionBasket met à jour le panier en cache Redis
|
||||
// Appelé après ajout/modification d'un produit au panier
|
||||
func (d *Database) UpdateSessionBasket(clientID int, basketItems []BasketItemCache) error {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
|
||||
// Vider le panier existant
|
||||
Redis.Del(RedisCtx, basketKey)
|
||||
|
||||
// Ajouter tous les items
|
||||
for _, item := range basketItems {
|
||||
itemJSON, _ := json.Marshal(item)
|
||||
if err := Redis.HSet(RedisCtx, basketKey, item.ProductID, itemJSON).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur ajout item: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TTL: 24 heures
|
||||
if err := Redis.Expire(RedisCtx, basketKey, 24*time.Hour).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur TTL: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSessionBasket vide le panier en cache Redis
|
||||
// Appelé après validation de commande (checkout)
|
||||
func (d *Database) ClearSessionBasket(clientID int) error {
|
||||
basketKey := fmt.Sprintf("session:basket:%d", clientID)
|
||||
if err := Redis.Del(RedisCtx, basketKey).Err(); err != nil {
|
||||
log.Printf("⚠️ [BASKET] Erreur clear: %v", err)
|
||||
return nil
|
||||
}
|
||||
log.Printf("✅ [BASKET] Panier vidé pour client %d", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// UTILITAIRES SESSION
|
||||
// ============================================
|
||||
|
||||
// GetAllActiveSessions récupère toutes les sessions actives
|
||||
// Utile pour admin/stats
|
||||
func (d *Database) GetAllActiveSessions() ([]SessionData, error) {
|
||||
clientIDs, err := Redis.SMembers(RedisCtx, "session:active:clients").Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("erreur récupération sessions: %w", err)
|
||||
}
|
||||
|
||||
var sessions []SessionData
|
||||
for _, clientIDStr := range clientIDs {
|
||||
var clientID int
|
||||
if _, err := fmt.Sscanf(clientIDStr, "%d", &clientID); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if session, err := d.GetClientSession(clientID); err == nil {
|
||||
sessions = append(sessions, *session)
|
||||
}
|
||||
}
|
||||
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// GetSessionCount retourne le nombre de sessions actives
|
||||
func (d *Database) GetSessionCount() (int64, error) {
|
||||
count, err := Redis.SCard(RedisCtx, "session:active:clients").Result()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("erreur comptage sessions: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// CACHE PROFIL CLIENT
|
||||
// ============================================
|
||||
|
||||
// CacheClientProfile met en cache les infos du client (pour 1h)
|
||||
func (d *Database) CacheClientProfile(client interface{}) error {
|
||||
// Récupérer le client depuis DB si c'est un username
|
||||
var clientData *models.Client
|
||||
|
||||
// Si c'est un username string
|
||||
if username, ok := client.(string); ok {
|
||||
var err error
|
||||
clientData, err = d.GetClientByUsername(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client non trouvé: %w", err)
|
||||
}
|
||||
} else {
|
||||
// Si c'est déjà un *models.Client
|
||||
clientData = client.(*models.Client)
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientData.ID)
|
||||
|
||||
// Sérialiser
|
||||
profileJSON, err := json.Marshal(clientData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation: %w", err)
|
||||
}
|
||||
|
||||
// Sauvegarder avec TTL 1h
|
||||
if err := Redis.Set(RedisCtx, cacheKey, profileJSON, 1*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur cache: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("✅ [CACHE] Profil client %d mis en cache (1h)", clientData.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedClientProfile récupère le profil en cache
|
||||
func (d *Database) GetCachedClientProfile(clientID int) (*models.Client, error) {
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, cacheKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache miss")
|
||||
}
|
||||
|
||||
var client models.Client
|
||||
if err := json.Unmarshal([]byte(data), &client); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
// InvalidateClientCache invalide le cache du client
|
||||
func (d *Database) InvalidateClientCache(clientID int) error {
|
||||
cacheKey := fmt.Sprintf("cache:client:profile:%d", clientID)
|
||||
if err := Redis.Del(RedisCtx, cacheKey).Err(); err != nil {
|
||||
return fmt.Errorf("erreur invalidation: %w", err)
|
||||
}
|
||||
log.Printf("✅ [CACHE] Profil client %d invalidé", clientID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COMMANDES EN CACHE (POUR TRACKING)
|
||||
// ============================================
|
||||
|
||||
// CacheCommandInfo met en cache les infos d'une commande
|
||||
func (d *Database) CacheCommandInfo(commandID int, command map[string]interface{}) error {
|
||||
cacheKey := fmt.Sprintf("cache:command:%d", commandID)
|
||||
|
||||
commandJSON, err := json.Marshal(command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("erreur sérialisation: %w", err)
|
||||
}
|
||||
|
||||
// TTL: 4 heures
|
||||
if err := Redis.Set(RedisCtx, cacheKey, commandJSON, 4*time.Hour).Err(); err != nil {
|
||||
return fmt.Errorf("erreur cache: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCachedCommand récupère une commande en cache
|
||||
func (d *Database) GetCachedCommand(commandID int) (map[string]interface{}, error) {
|
||||
cacheKey := fmt.Sprintf("cache:command:%d", commandID)
|
||||
|
||||
data, err := Redis.Get(RedisCtx, cacheKey).Result()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cache miss")
|
||||
}
|
||||
|
||||
var command map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(data), &command); err != nil {
|
||||
return nil, fmt.Errorf("erreur désérialisation: %w", err)
|
||||
}
|
||||
|
||||
return command, nil
|
||||
}
|
||||
Reference in New Issue
Block a user