chore: add ansible backend docker frontend-prep

This commit is contained in:
2026-01-21 13:05:13 +01:00
parent 5a280b6b01
commit 943fe4de7d
14930 changed files with 2341433 additions and 0 deletions
+188
View File
@@ -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
}
+367
View File
@@ -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
}
+454
View File
@@ -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(&currentStatus, &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(&currentPointsWeed, &currentPointsZipette)
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(&currentStatus, &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
}
+942
View File
@@ -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
}
+461
View File
@@ -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
}
+274
View File
@@ -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
+383
View File
@@ -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(&currentStatus, &currentLivreur)
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, &currentStatus, &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
}
+103
View File
@@ -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
}
+442
View File
@@ -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", &currentID)
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
}
+147
View File
@@ -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
}
+349
View File
@@ -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
}
+335
View File
@@ -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
}
+167
View File
@@ -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
}
+420
View File
@@ -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
}
+44
View File
@@ -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
}
+256
View File
@@ -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
}
+81
View File
@@ -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
}
+244
View File
@@ -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
}
+175
View File
@@ -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
}
+208
View File
@@ -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
}
+57
View File
@@ -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
}
+196
View File
@@ -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
}
+29
View File
@@ -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, ""}
}
+117
View File
@@ -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
}
+39
View File
@@ -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
}
+273
View File
@@ -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
}
+169
View File
@@ -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
}
+404
View File
@@ -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
}