chore: update

This commit is contained in:
2026-03-11 19:57:34 +01:00
parent f6b3948839
commit 375aa38454
62 changed files with 2714 additions and 1981 deletions
+33
View File
@@ -0,0 +1,33 @@
version: "2"
linters:
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- unused
- gosec
- gocritic
- misspell
- bodyclose
- noctx
linters-settings:
gosec:
excludes:
- G104 # erreurs non vérifiées (couvertes par errcheck)
gocritic:
disabled-checks:
- appendAssign
- sloppyReassign
issues:
exclude-rules:
- path: _test\.go
linters:
- gosec
- errcheck
max-issues-per-linter: 50
max-same-issues: 5
+13 -13
View File
@@ -5,15 +5,15 @@ import (
"gestion/models"
)
func (d *Database) CreateAlert(username string) (models.AlertPolicy, error) {
func (d *Database) CreateAlert(username string, message string) (models.AlertPolicy, error) {
query := `
INSERT INTO alerte_policy (username, status)
VALUES ($1, 'true')
RETURNING id, username, status, created_at, updated_at
INSERT INTO alerte_policy (username, status, message)
VALUES ($1, 'true', $2)
RETURNING id, username, status, message, created_at, updated_at
`
var alert models.AlertPolicy
err := d.QueryRow(query, username).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := d.QueryRow(query, username, message).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return models.AlertPolicy{}, err
}
@@ -25,12 +25,12 @@ func (d *Database) CreateAlert(username string) (models.AlertPolicy, error) {
func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
SELECT id, username, status, message, 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)
err := d.QueryRow(query, id).Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return models.AlertPolicy{}, err
}
@@ -42,7 +42,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) {
func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
SELECT id, username, status, message, created_at, updated_at
FROM alerte_policy
`
rows, err := d.Query(query)
@@ -54,7 +54,7 @@ func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
@@ -134,7 +134,7 @@ func (d *Database) ActivateAlert(id int) error {
func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
SELECT id, username, status, message, created_at, updated_at
FROM alerte_policy
WHERE status = 'true'
ORDER BY created_at DESC
@@ -148,7 +148,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
@@ -162,7 +162,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) {
func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) {
query := `
SELECT id, username, status, created_at, updated_at
SELECT id, username, status, message, created_at, updated_at
FROM alerte_policy
WHERE username = $1
ORDER BY created_at DESC
@@ -176,7 +176,7 @@ func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, e
var alerts []models.AlertPolicy
for rows.Next() {
var alert models.AlertPolicy
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.CreatedAt, &alert.UpdatedAt)
err := rows.Scan(&alert.ID, &alert.Username, &alert.Status, &alert.Message, &alert.CreatedAt, &alert.UpdatedAt)
if err != nil {
return nil, err
}
+103
View File
@@ -0,0 +1,103 @@
package db
import (
"fmt"
"regexp"
)
var hexColorRegex = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
type Category struct {
ID int `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
CreatedAt string `json:"created_at"`
}
func ValidateCategoryColor(color string) error {
if color == "" {
return nil // valeur par défaut utilisée
}
if !hexColorRegex.MatchString(color) {
return fmt.Errorf("couleur invalide : format hexadécimal requis (ex: #7c3aed)")
}
return nil
}
func (d *Database) GetAllCategories() ([]Category, error) {
rows, err := d.Query(`SELECT id, name, color, is_coming_soon, created_at FROM categories ORDER BY name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var categories []Category
for rows.Next() {
var c Category
if err := rows.Scan(&c.ID, &c.Name, &c.Color, &c.IsComingSoon, &c.CreatedAt); err != nil {
return nil, err
}
categories = append(categories, c)
}
if categories == nil {
categories = []Category{}
}
return categories, nil
}
func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Category, error) {
if color == "" {
color = "#7c3aed"
}
var c Category
err := d.QueryRow(
`INSERT INTO categories (name, color, is_coming_soon) VALUES ($1, $2, $3) RETURNING id, name, color, is_coming_soon, created_at`,
name, color, isComingSoon,
).Scan(&c.ID, &c.Name, &c.Color, &c.IsComingSoon, &c.CreatedAt)
if err != nil {
return nil, err
}
return &c, nil
}
func (d *Database) UpdateCategory(id int, name, color string, isComingSoon bool) (*Category, error) {
if color == "" {
color = "#7c3aed"
}
var c Category
err := d.QueryRow(
`UPDATE categories SET name = $1, color = $2, is_coming_soon = $3 WHERE id = $4 RETURNING id, name, color, is_coming_soon, created_at`,
name, color, isComingSoon, id,
).Scan(&c.ID, &c.Name, &c.Color, &c.IsComingSoon, &c.CreatedAt)
if err != nil {
return nil, err
}
return &c, nil
}
func (d *Database) DeleteCategory(id int) error {
var count int
err := d.QueryRow(`SELECT COUNT(*) FROM products WHERE category = (SELECT name FROM categories WHERE id = $1)`, id).Scan(&count)
if err != nil {
return err
}
if count > 0 {
return fmt.Errorf("catégorie utilisée par %d produit(s)", count)
}
res, err := d.Exec(`DELETE FROM categories WHERE id = $1`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("catégorie non trouvée")
}
return nil
}
func (d *Database) CategoryExists(name string) (bool, error) {
var count int
err := d.QueryRow(`SELECT COUNT(*) FROM categories WHERE name = $1`, name).Scan(&count)
return count > 0, err
}
+120 -88
View File
@@ -57,7 +57,7 @@ func (d *Database) GetClientByID(id int) (*models.Client, error) {
// 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
query := `SELECT id, username, password, nom, prenom, telephone, command, point, point_zipette, amende, referral_balance, created_at
FROM clients ORDER BY created_at DESC`
rows, err := d.Query(query)
@@ -80,6 +80,7 @@ func (d *Database) GetAllClients() ([]*models.Client, error) {
&client.Point,
&client.PointZipette,
&client.Amende,
&client.ReferralBalance,
&client.CreatedAt,
)
if err != nil {
@@ -212,7 +213,7 @@ func (d *Database) GetClientStats(clientID int) (map[string]interface{}, error)
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 = 'pending' 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`
@@ -856,128 +857,159 @@ func (d *Database) CalculateAndAddPointsForCommand(commandID int, username strin
return totalPoints, nil
}
func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, username string) (int, error) {
func (d *Database) CalculateAndAddPointsForCommandTx(tx *sql.Tx, commandID int, username string) (int, string, error) {
log.Printf("💰 [CalcPointsTx] START - cmd=%d, user=%s", commandID, username)
// Charger les paramètres globaux
settings, err := d.GetSettings()
if err != nil {
log.Printf("⚠️ [CalcPointsTx] Erreur lecture settings, utilisation des défauts: %v", err)
settings = DefaultSettings()
}
// Construire les sets de catégories par pool
weedCats := make(map[string]bool)
for _, cat := range settings.PointsCategoriesWeed {
weedCats[strings.ToLower(cat)] = true
}
zipetteCats := make(map[string]bool)
for _, cat := range settings.PointsCategoriesZipette {
zipetteCats[strings.ToLower(cat)] = true
}
// pool "total" → toujours compté dans le pool weed (point)
totalCats := make(map[string]bool)
for _, cat := range settings.PointsCategoriesTotal {
totalCats[strings.ToLower(cat)] = true
}
// Si aucune catégorie configurée → pas de points
if !settings.PointsSeparated {
// Mode non-séparé : seul le pool Total est actif
if len(weedCats) == 0 && len(zipetteCats) == 0 && len(totalCats) == 0 {
log.Printf("️ [CalcPointsTx] Mode non-séparé : aucune catégorie configurée → 0 points")
return 0, "", nil
}
} else {
// Mode séparé : seuls W et Z sont actifs (T ignoré)
if len(weedCats) == 0 && len(zipetteCats) == 0 {
log.Printf("️ [CalcPointsTx] Mode séparé : aucune catégorie W/Z configurée → 0 points")
return 0, "", nil
}
}
// ✅ É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
rows, err := tx.Query(`
SELECT ci.quantite, ci.prix, COALESCE(p.category, '') 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)
`, commandID)
if err != nil {
log.Printf("❌ [CalcPointsTx] Erreur query items: %v", err)
return 0, fmt.Errorf("erreur récupération items: %w", err)
return 0, "", fmt.Errorf("erreur récupération items: %w", err)
}
defer rows.Close()
type ItemPoints struct {
Category string
Quantite float64
Prix float64
}
var items []ItemPoints
totalPrixWeedHash := 0.0
var itemCount int
totalPrixWeed := 0.0
totalPrixZipette := 0.0
totalPrixTotal := 0.0
for rows.Next() {
var item ItemPoints
if err := rows.Scan(&item.Quantite, &item.Prix, &item.Category); err != nil {
var quantite, prix float64
var category string
if err := rows.Scan(&quantite, &prix, &category); err != nil {
log.Printf("❌ [CalcPointsTx] Erreur scan: %v", err)
return 0, fmt.Errorf("erreur lecture item: %w", err)
return 0, "", fmt.Errorf("erreur lecture item: %w", err)
}
items = append(items, item)
// Cumuler par catégorie
categoryLower := strings.ToLower(item.Category)
if categoryLower == "zipette&co" || categoryLower == "zipette_co" {
totalPrixZipette += item.Prix
} else if categoryLower == "gros&semi" || categoryLower == "gros_semi" {
// gros&semi → 0 points, on ne cumule pas
} else {
// weed_hash ou autres catégories
totalPrixWeedHash += item.Prix
itemCount++
catLower := strings.ToLower(category)
if weedCats[catLower] {
totalPrixWeed += prix
} else if zipetteCats[catLower] {
totalPrixZipette += prix
} else if totalCats[catLower] {
totalPrixTotal += prix
}
}
if err = rows.Err(); err != nil {
log.Printf("❌ [CalcPointsTx] Erreur rows: %v", err)
return 0, fmt.Errorf("erreur itération items: %w", err)
return 0, "", fmt.Errorf("erreur itération items: %w", err)
}
if len(items) == 0 {
if itemCount == 0 {
log.Printf("⚠️ [CalcPointsTx] Aucun item trouvé pour cmd %d", commandID)
return 0, nil
return 0, "", nil
}
log.Printf("📊 [CalcPointsTx] %d items - weed_hash: %.2f€, zipette: %.2f€",
len(items), totalPrixWeedHash, totalPrixZipette)
log.Printf("📊 [CalcPointsTx] %d items - weed: %.2f€, zipette: %.2f€, total: %.2f€",
itemCount, totalPrixWeed, totalPrixZipette, totalPrixTotal)
// ✅ ÉTAPE 2: Calculer les points par catégorie avec le bon barème
pointsWeedHash := 0
switch {
case totalPrixWeedHash >= 30 && totalPrixWeedHash <= 50:
pointsWeedHash = 1
case totalPrixWeedHash >= 60 && totalPrixWeedHash <= 150:
pointsWeedHash = 2
case totalPrixWeedHash >= 160 && totalPrixWeedHash <= 300:
pointsWeedHash = 3
case totalPrixWeedHash >= 310 && totalPrixWeedHash <= 400:
pointsWeedHash = 5
case totalPrixWeedHash >= 400:
pointsWeedHash = 10
}
// ✅ ÉTAPE 2: Calculer les points
var totalPoints int
var pointCategory string
var result sql.Result
pointsZipette := 0
switch {
case totalPrixZipette >= 30 && totalPrixZipette <= 100:
pointsZipette = 1
case totalPrixZipette >= 110 && totalPrixZipette <= 200:
pointsZipette = 2
case totalPrixZipette >= 210:
pointsZipette = 3
}
if !settings.PointsSeparated {
// ── Mode non-séparé : Barème Total appliqué sur W + Z + T ──────────────
allTotal := totalPrixWeed + totalPrixZipette + totalPrixTotal
totalPoints = CalcPointsFromTiers(allTotal, settings.PointsTotalTiers)
pointCategory = "total"
totalPoints := pointsWeedHash + pointsZipette
log.Printf("💰 [CalcPointsTx] Mode non-séparé - total: %.2f€ → %d pts (barème Total)",
allTotal, totalPoints)
log.Printf("💰 [CalcPointsTx] Points calculés - weed_hash: %d, zipette: %d, total: %d",
pointsWeedHash, pointsZipette, totalPoints)
if totalPoints == 0 {
return 0, "", nil
}
// ✅ ÉTAPE 3: Mettre à jour les points du client (dans la transaction)
if pointsWeedHash > 0 || pointsZipette > 0 {
updateQuery := `
result, err = tx.Exec(`
UPDATE clients
SET
point = point + $1,
point_zipette = point_zipette + $2,
updated_at = CURRENT_TIMESTAMP
SET point = point + $1, updated_at = CURRENT_TIMESTAMP
WHERE username = $2
`, totalPoints, username)
} else {
// ── Mode séparé : Barèmes W et Z, pool T ignoré ────────────────────────
pointsWeed := CalcPointsFromTiers(totalPrixWeed, settings.PointsWeedTiers)
pointsZipette := CalcPointsFromTiers(totalPrixZipette, settings.PointsZipetteTiers)
totalPoints = pointsWeed + pointsZipette
log.Printf("💰 [CalcPointsTx] Mode séparé - weed: %d pts, zipette: %d pts",
pointsWeed, pointsZipette)
if totalPoints == 0 {
return 0, "", nil
}
if pointsWeed > 0 && pointsZipette > 0 {
pointCategory = "mixed"
} else if pointsZipette > 0 {
pointCategory = "zipette&co"
} else {
pointCategory = "weed&hash"
}
result, err = tx.Exec(`
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)
`, pointsWeed, pointsZipette, username)
}
log.Printf("🎉 [CalcPointsTx] SUCCÈS - Total %d points attribués", totalPoints)
if err != nil {
log.Printf("❌ [CalcPointsTx] Erreur UPDATE points: %v", err)
return 0, "", fmt.Errorf("erreur mise à jour points: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
log.Printf("⚠️ [CalcPointsTx] Client %s non trouvé", username)
return 0, "", fmt.Errorf("client non trouvé")
}
return totalPoints, nil
log.Printf("🎉 [CalcPointsTx] SUCCÈS - %d points [%s] → %s", totalPoints, pointCategory, username)
return totalPoints, pointCategory, nil
}
func (d *Database) CanUserAccessCommand(
+4 -1
View File
@@ -213,6 +213,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
c.status as command_status,
c.adresse as command_address,
c.total_prix,
c.referral_used,
c.livreur_assign,
c.created_at as command_created_at,
COALESCE(p.category, 'weed_hash') as category
@@ -241,13 +242,14 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
var createdAt, updatedAt, commandCreatedAt time.Time
var commandStatus, commandAddress, livreurAssign sql.NullString
var category string
var referralUsed float64
// ✅ 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,
&commandStatus, &commandAddress, &totalPrix, &referralUsed, &livreurAssign, &commandCreatedAt,
&category,
)
@@ -281,6 +283,7 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]interface{}, err
"command_status": commandStatus.String,
"command_address": commandAddress.String,
"total_prix": totalPrix,
"referral_used": referralUsed,
"livreur_assign": livreurAssign.String,
"command_created_at": commandCreatedAt,
"category": category,
+1 -1
View File
@@ -26,7 +26,7 @@ func (d *Database) GetAllCommandsOldestFirst(status, username string) ([]map[str
// Filtrer par status
if status != "" {
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled", "support"}
validStatuses := []string{"pending", "assigned", "en_route", "livre", "approved", "cancelled", "disabled"}
isValid := false
for _, vs := range validStatuses {
if status == vs {
+40 -34
View File
@@ -53,7 +53,6 @@ func validateCommandStatus(status string) error {
"approved": true,
"cancelled": true,
"disabled": true,
"support": true,
}
if !validStatuses[status] {
@@ -423,10 +422,15 @@ func (d *Database) GetCommandCount() (int, error) {
return count, nil
}
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
_, err := d.Exec(`UPDATE commandes SET referral_used = $1 WHERE id = $2`, amount, commandID)
return err
}
func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
// ✅ Déjà sécurisé avec paramètre $1
query := `SELECT id, username, status, adresse, total_prix, livreur_assign, created_at, updated_at,
proposed_address, address_proposal_status
proposed_address, address_proposal_status, referral_used
FROM commandes WHERE id = $1`
var commandID int
@@ -434,7 +438,7 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
var livreurAssign sql.NullString
var proposedAddress sql.NullString
var addressProposalStatus string
var totalPrix float64
var totalPrix, referralUsed float64
var createdAt, updatedAt time.Time
err := d.QueryRow(query, id).Scan(
@@ -448,6 +452,7 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
&updatedAt,
&proposedAddress,
&addressProposalStatus,
&referralUsed,
)
if err == sql.ErrNoRows {
@@ -466,6 +471,7 @@ func (d *Database) GetCommandByID(id int) (map[string]interface{}, error) {
"created_at": createdAt,
"updated_at": updatedAt,
"address_proposal_status": addressProposalStatus,
"referral_used": referralUsed,
}
if livreurAssign.Valid {
@@ -595,7 +601,7 @@ func (d *Database) RespondToAddressProposal(commandID int, clientUsername string
// UpdateCommandStatus met à jour le statut d'une commande
func (d *Database) UpdateCommandStatus(commandID int, status string) error {
// ✅ SÉCURITÉ: Validation du statut
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled", "support"}
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled"}
isValid := false
for _, vs := range validStatuses {
if status == vs {
@@ -706,7 +712,7 @@ func (d *Database) GetCommandsWithFilter(status, username string, excludeApprove
// ✅ Filtrer par status si fourni avec validation
if status != "" {
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled", "support"}
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre", "approved", "cancelled", "disabled"}
isValid := false
for _, vs := range validStatuses {
if status == vs {
@@ -832,7 +838,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
currentStatus, cmdUsername, livreurAssign)
// ✅ ÉTAPE 3: Vérifier que le statut permet la validation
validStatuses := []string{"assigned", "en_route", "pending", "support", "livre"}
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
isValid := false
for _, s := range validStatuses {
if currentStatus == s {
@@ -878,7 +884,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmdUsername)
// Utiliser la version transactionnelle du calcul de points
points, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmdUsername)
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmdUsername)
if err != nil {
log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err)
return 0, fmt.Errorf("erreur attribution points: %w", err)
@@ -957,14 +963,14 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
}
// ApproveDeliveryAtomic - Version atomique pour approbation client
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, error) {
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) {
log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username)
// ✅ ÉTAPE 1: Démarrer une transaction
tx, err := d.Begin()
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur début transaction: %v", err)
return 0, fmt.Errorf("erreur transaction: %w", err)
return 0, "", fmt.Errorf("erreur transaction: %w", err)
}
defer tx.Rollback()
@@ -979,11 +985,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
if err == sql.ErrNoRows {
log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID)
return 0, fmt.Errorf("commande non trouvée")
return 0, "", fmt.Errorf("commande non trouvée")
}
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err)
return 0, fmt.Errorf("erreur lecture commande: %w", err)
return 0, "", fmt.Errorf("erreur lecture commande: %w", err)
}
log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", currentStatus, cmdUsername)
@@ -992,13 +998,13 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
if cmdUsername != username {
log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)",
username, cmdUsername)
return 0, fmt.Errorf("cette commande ne vous appartient pas")
return 0, "", fmt.Errorf("cette commande ne vous appartient pas")
}
// ✅ ÉTAPE 4: Vérifier le statut
if currentStatus != "livre" {
log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", currentStatus)
return 0, fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
return 0, "", fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
}
// ✅ ÉTAPE 5: UPDATE avec vérification du statut
@@ -1010,25 +1016,25 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", err)
return 0, fmt.Errorf("erreur mise à jour statut: %w", err)
return 0, "", fmt.Errorf("erreur mise à jour statut: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
return 0, fmt.Errorf("commande déjà approuvée ou modifiée")
return 0, "", fmt.Errorf("commande déjà approuvée ou modifiée")
}
log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved")
// ✅ ÉTAPE 6: Calculer et ajouter les points
totalPoints, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
totalPoints, pointCategory, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
if err != nil {
log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err)
return 0, fmt.Errorf("erreur attribution points: %w", err)
return 0, "", fmt.Errorf("erreur attribution points: %w", err)
}
log.Printf("✅ [ApproveAtomic] %d points attribués à %s", totalPoints, username)
log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username)
// ✅ ÉTAPE 7: Incrémenter le compteur de commandes
_, err = tx.Exec(`
@@ -1046,7 +1052,7 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
`, commandID, "approved",
fmt.Sprintf("Livraison confirmée par le client %s - %d points attribués", username, totalPoints),
fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory),
username)
if err != nil {
@@ -1056,11 +1062,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
// ✅ ÉTAPE 9: Commit
if err := tx.Commit(); err != nil {
log.Printf("❌ [ApproveAtomic] Erreur COMMIT: %v", err)
return 0, fmt.Errorf("erreur commit transaction: %w", err)
return 0, "", fmt.Errorf("erreur commit transaction: %w", err)
}
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points attribués",
commandID, totalPoints)
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués",
commandID, totalPoints, pointCategory)
// ✅ ÉTAPE 10: Optimiser queue livreur (async, après commit)
if livreurAssign != "" {
@@ -1087,16 +1093,16 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, e
log.Printf("✅ [ApproveAtomic] Caches invalidés")
}()
return totalPoints, nil
return totalPoints, pointCategory, nil
}
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, error) {
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) {
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
tx, err := d.Begin()
if err != nil {
return 0, "", fmt.Errorf("erreur transaction: %w", err)
return 0, "", "", fmt.Errorf("erreur transaction: %w", err)
}
defer tx.Rollback()
@@ -1109,14 +1115,14 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
`, commandID).Scan(&currentStatus, &clientUsername, &livreurAssign)
if err == sql.ErrNoRows {
return 0, "", fmt.Errorf("commande non trouvée")
return 0, "", "", fmt.Errorf("commande non trouvée")
}
if err != nil {
return 0, "", fmt.Errorf("erreur lecture commande: %w", err)
return 0, "", "", fmt.Errorf("erreur lecture commande: %w", err)
}
if currentStatus != "livre" {
return 0, "", fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
return 0, "", "", fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", currentStatus)
}
result, err := tx.Exec(`
@@ -1125,17 +1131,17 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
WHERE id = $1 AND status = 'livre'
`, commandID)
if err != nil {
return 0, "", fmt.Errorf("erreur mise à jour statut: %w", err)
return 0, "", "", fmt.Errorf("erreur mise à jour statut: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return 0, "", fmt.Errorf("commande déjà approuvée ou modifiée")
return 0, "", "", fmt.Errorf("commande déjà approuvée ou modifiée")
}
totalPoints, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, clientUsername)
totalPoints, pointCategory, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, clientUsername)
if err != nil {
return 0, "", fmt.Errorf("erreur attribution points: %w", err)
return 0, "", "", fmt.Errorf("erreur attribution points: %w", err)
}
_, err = tx.Exec(`
@@ -1158,7 +1164,7 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
}
if err := tx.Commit(); err != nil {
return 0, "", fmt.Errorf("erreur commit: %w", err)
return 0, "", "", fmt.Errorf("erreur commit: %w", err)
}
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
@@ -1178,5 +1184,5 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsername))
}()
return totalPoints, clientUsername, nil
return totalPoints, pointCategory, clientUsername, nil
}
+7 -7
View File
@@ -111,7 +111,7 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
}
// ✅ Vérifier le statut
validStatusesForAssignment := []string{"pending", "support"}
validStatusesForAssignment := []string{"pending"}
isValidStatus := false
for _, vs := range validStatusesForAssignment {
if currentStatus == vs {
@@ -132,10 +132,10 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
// ============================================
updateQuery := `UPDATE commandes
SET livreur_assign = $1,
status = 'support',
status = 'assigned',
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
AND status IN ('pending', 'support')
AND status IN ('pending')
AND (livreur_assign IS NULL OR livreur_assign = '' OR livreur_assign = $1)`
result, err := tx.Exec(updateQuery, livreurUsername, commandID)
@@ -159,10 +159,10 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
// ============================================
// ÉTAPE 5: Ajouter un log (DANS la transaction)
// ============================================
logQuery := `INSERT INTO command_logs (command_id, status, message, actor, created_at)
logQuery := `INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
_, err = tx.Exec(logQuery, commandID, "support",
_, err = tx.Exec(logQuery, commandID, "assigned",
fmt.Sprintf("Livraison assignée au livreur %s", livreurUsername),
"admin")
if err != nil {
@@ -180,7 +180,7 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e
}
log.Printf("🎉 [AssignDeliveryPerson] SUCCÈS - Commande %d assignée à %s", commandID, livreurUsername)
log.Printf(" Workflow: pending → ✅ support (TRANSACTION COMMITTED)")
log.Printf(" Workflow: pending → ✅ assigned (TRANSACTION COMMITTED)")
return nil
}
@@ -356,7 +356,7 @@ func (d *Database) ApproveDelivery(commandID int, clientUsername string) error {
// ============================================
// ÉTAPE 5: Ajouter un log (DANS la transaction)
// ============================================
logQuery := `INSERT INTO command_logs (command_id, status, message, actor, created_at)
logQuery := `INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)`
_, err = tx.Exec(logQuery, commandID, "approved",
-1
View File
@@ -84,7 +84,6 @@ func ValidateStatuses(statuses string) ([]string, error) {
// Liste blanche complète
validStatusMap := map[string]bool{
"pending": true,
"support": true,
"assigned": true,
"en_route": true,
"arrived": true,
+40
View File
@@ -133,6 +133,34 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration command_items.quantite: %v", err)
}
// Migration: ajouter colonne color pour les catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS color VARCHAR(7) NOT NULL DEFAULT '#7c3aed'`); err != nil {
log.Fatalf("❌ Erreur migration categories.color: %v", err)
}
// Migration: ajouter colonne is_coming_soon pour les catégories
if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS is_coming_soon BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err)
}
// Migration: table paramètres globaux de l'application
if _, err = database.Exec(`CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL
)`); err != nil {
log.Fatalf("❌ Erreur migration app_settings: %v", err)
}
// Migration: ajouter colonne message pour les alertes police (type d'alerte)
if _, err = database.Exec(`ALTER TABLE alerte_policy ADD COLUMN IF NOT EXISTS message VARCHAR(200) NOT NULL DEFAULT ''`); err != nil {
log.Fatalf("❌ Erreur migration alerte_policy.message: %v", err)
}
// Migration: montant de parrainage utilisé pour la commande
if _, err = database.Exec(`ALTER TABLE commandes ADD COLUMN IF NOT EXISTS referral_used FLOAT NOT NULL DEFAULT 0`); err != nil {
log.Fatalf("❌ Erreur migration commandes.referral_used: %v", err)
}
// Lancer le nettoyage périodique des tokens expirés
go database.cleanExpiredTokensPeriodically()
@@ -178,9 +206,11 @@ func (db *Database) createTables() error {
cancel_commande INTEGER DEFAULT 0,
cancellations_count INTEGER DEFAULT 0 NOT NULL,
last_penalty_reason TEXT DEFAULT NULL,
referral_balance NUMERIC(10,2) DEFAULT 0.0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
`ALTER TABLE clients ADD COLUMN IF NOT EXISTS referral_balance NUMERIC(10,2) DEFAULT 0.0;`,
// ============================
// TABLE jwt_tokens
@@ -195,6 +225,16 @@ func (db *Database) createTables() error {
CHECK (date_fin > date_save)
);`,
// ============================
// TABLE categories
// ============================
`CREATE TABLE IF NOT EXISTS categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
color VARCHAR(7) NOT NULL DEFAULT '#7c3aed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);`,
// ============================
// TABLE products
// ============================
+41
View File
@@ -131,6 +131,47 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess
return nil
}
// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines
// et envoie un push aux ceux qui ont un token. Appelé dès qu'une nouvelle commande est créée.
func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) {
rows, err := d.Query(
`SELECT username, COALESCE(push_token, '') FROM users WHERE role IN ('admin','cabine')`,
)
if err != nil {
log.Printf("❌ [ADMIN_NOTIF] Erreur lecture users admin/cabine: %v", err)
return
}
defer rows.Close()
msg := fmt.Sprintf("Nouvelle commande #%d de %s — %s", commandID, clientUsername, deliveryAddr)
notification := map[string]interface{}{
"command_id": commandID,
"type": "new_order",
"message": msg,
"created_at": time.Now().Format(time.RFC3339),
"read": false,
}
notifJSON, _ := json.Marshal(notification)
sent := 0
for rows.Next() {
var username, token string
if err := rows.Scan(&username, &token); err != nil {
continue
}
notifKey := fmt.Sprintf("notifications:%s", username)
Redis.LPush(RedisCtx, notifKey, notifJSON)
Redis.Expire(RedisCtx, notifKey, 7*24*time.Hour)
if token != "" {
go sendExpoPushWithChannel(token, "Nouvelle commande", msg, commandID, "new_order", "orders")
sent++
}
}
log.Printf("📬 [ADMIN_NOTIF] Notif Redis + push (%d tokens) pour commande #%d", sent, commandID)
}
// AddDeliveryRating ajoute une note pour un livreur
func (d *Database) AddDeliveryRating(livreurUsername string, commandID, rating int, comment string) error {
query := `
+62
View File
@@ -0,0 +1,62 @@
package db
import (
"database/sql"
"fmt"
)
// GetClientReferralBalance retourne le solde parrainage d'un client.
func (d *Database) GetClientReferralBalance(username string) (float64, error) {
var balance float64
err := d.QueryRow(
`SELECT referral_balance FROM clients WHERE username = $1`,
username,
).Scan(&balance)
if err == sql.ErrNoRows {
return 0, fmt.Errorf("client non trouvé")
}
return balance, err
}
// CreditClientReferral ajoute un montant au solde parrainage d'un client.
func (d *Database) CreditClientReferral(username string, amount float64) error {
if amount <= 0 {
return fmt.Errorf("le montant doit être positif")
}
res, err := d.Exec(
`UPDATE clients SET referral_balance = referral_balance + $1 WHERE username = $2`,
amount, username,
)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return fmt.Errorf("client non trouvé")
}
return nil
}
// UseClientReferralBalance déduit un montant du solde parrainage dans une transaction.
// Retourne une erreur si le solde est insuffisant.
func (d *Database) UseClientReferralBalance(tx *sql.Tx, username string, amount float64) error {
if amount <= 0 {
return nil
}
var balance float64
err := tx.QueryRow(
`SELECT referral_balance FROM clients WHERE username = $1 FOR UPDATE`,
username,
).Scan(&balance)
if err != nil {
return fmt.Errorf("client non trouvé")
}
if balance < amount {
return fmt.Errorf("solde parrainage insuffisant (disponible: %.2f€)", balance)
}
_, err = tx.Exec(
`UPDATE clients SET referral_balance = referral_balance - $1 WHERE username = $2`,
amount, username,
)
return err
}
+285
View File
@@ -0,0 +1,285 @@
package db
import (
"encoding/json"
"fmt"
)
// DaySchedule représente les horaires de livraison pour un jour de la semaine
type DaySchedule struct {
Enabled bool `json:"enabled"`
OpenTime string `json:"open_time"` // ex: "09:00"
CloseTime string `json:"close_time"` // ex: "20:00"
}
// DeliverySchedule représente les horaires de livraison pour chaque jour
type DeliverySchedule struct {
Monday DaySchedule `json:"monday"`
Tuesday DaySchedule `json:"tuesday"`
Wednesday DaySchedule `json:"wednesday"`
Thursday DaySchedule `json:"thursday"`
Friday DaySchedule `json:"friday"`
Saturday DaySchedule `json:"saturday"`
Sunday DaySchedule `json:"sunday"`
}
// DefaultDeliverySchedule retourne un planning de livraison par défaut (tous les jours, 9h-20h)
func DefaultDeliverySchedule() DeliverySchedule {
day := DaySchedule{Enabled: true, OpenTime: "09:00", CloseTime: "20:00"}
return DeliverySchedule{
Monday: day, Tuesday: day, Wednesday: day, Thursday: day,
Friday: day, Saturday: day, Sunday: day,
}
}
// PostalZone représente une zone de livraison avec un minimum de commande
type PostalZone struct {
Name string `json:"name"`
MinAmount float64 `json:"min_amount"`
Codes []string `json:"codes"`
}
// PointsTier représente un palier du barème de points
// Si Max == 0, il n'y a pas de borne supérieure (illimité)
type PointsTier struct {
Min float64 `json:"min"`
Max float64 `json:"max"` // 0 = illimité
Points int `json:"points"`
}
// CalcPointsFromTiers retourne le nombre de points correspondant au total selon les paliers
func CalcPointsFromTiers(total float64, tiers []PointsTier) int {
for _, t := range tiers {
if total >= t.Min && (t.Max == 0 || total <= t.Max) {
return t.Points
}
}
return 0
}
// AppSettings contient les paramètres globaux de l'application
type AppSettings struct {
PenaltiesEnabled bool `json:"penalties_enabled"`
ShowAmendeScore bool `json:"show_amende_score"` // afficher le score d'amendes aux clients/cabine
PointsEnabled bool `json:"points_enabled"` // afficher/activer le système de points
PointsCategoriesWeed []string `json:"points_categories_weed"` // catégories → pool point (weed)
PointsCategoriesZipette []string `json:"points_categories_zipette"` // catégories → pool point_zipette
PointsCategoriesTotal []string `json:"points_categories_total"` // catégories → pool total (point, sans séparation)
PointsSeparated bool `json:"points_separated"` // true = weed/zipette séparés, false = tout dans point
// Barème de points par paliers configurables
PointsWeedTiers []PointsTier `json:"points_weed_tiers"`
PointsZipetteTiers []PointsTier `json:"points_zipette_tiers"`
PointsTotalTiers []PointsTier `json:"points_total_tiers"`
ReferralEnabled bool `json:"referral_enabled"` // activer/désactiver le système de parrainage
DeliverySchedule DeliverySchedule `json:"delivery_schedule"` // horaires de livraison par jour
PostalZones []PostalZone `json:"postal_zones"` // zones de livraison avec minimum de commande
}
// DefaultSettings retourne les paramètres par défaut
func DefaultSettings() AppSettings {
return AppSettings{
PenaltiesEnabled: true,
ShowAmendeScore: true,
PointsEnabled: true,
ReferralEnabled: true,
PointsCategoriesWeed: []string{},
PointsCategoriesZipette: []string{},
PointsCategoriesTotal: []string{},
PointsSeparated: true,
PointsWeedTiers: []PointsTier{
{Min: 30, Max: 50, Points: 1},
{Min: 60, Max: 150, Points: 2},
{Min: 160, Max: 300, Points: 3},
{Min: 310, Max: 400, Points: 5},
{Min: 401, Max: 0, Points: 10},
},
PointsZipetteTiers: []PointsTier{
{Min: 30, Max: 100, Points: 1},
{Min: 110, Max: 200, Points: 2},
{Min: 210, Max: 0, Points: 3},
},
PointsTotalTiers: []PointsTier{},
DeliverySchedule: DefaultDeliverySchedule(),
PostalZones: []PostalZone{
{Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}},
{Name: "Zone 50€", MinAmount: 50, Codes: []string{
"44400", "44880", "44120", "44230", "44115",
"44980", "44470", "44240", "44700", "44800", "44340", "44620", "44830",
}},
{Name: "Zone 100€", MinAmount: 100, Codes: []string{
"44860", "44220", "44118", "44710", "44690", "44119",
}},
},
}
}
// GetSettings récupère les paramètres depuis la DB
func (d *Database) GetSettings() (AppSettings, error) {
settings := DefaultSettings()
rows, err := d.Query(`SELECT key, value FROM app_settings`)
if err != nil {
return settings, fmt.Errorf("erreur lecture settings: %w", err)
}
defer rows.Close()
for rows.Next() {
var key, value string
if err := rows.Scan(&key, &value); err != nil {
continue
}
switch key {
case "penalties_enabled":
settings.PenaltiesEnabled = value == "true"
case "show_amende_score":
settings.ShowAmendeScore = value == "true"
case "points_enabled":
settings.PointsEnabled = value == "true"
case "points_categories_weed":
var cats []string
if err := json.Unmarshal([]byte(value), &cats); err == nil {
settings.PointsCategoriesWeed = cats
}
case "points_categories_zipette":
var cats []string
if err := json.Unmarshal([]byte(value), &cats); err == nil {
settings.PointsCategoriesZipette = cats
}
case "points_categories_total":
var cats []string
if err := json.Unmarshal([]byte(value), &cats); err == nil {
settings.PointsCategoriesTotal = cats
}
case "points_separated":
settings.PointsSeparated = value == "true"
case "points_weed_tiers":
var tiers []PointsTier
if err := json.Unmarshal([]byte(value), &tiers); err == nil {
settings.PointsWeedTiers = tiers
}
case "points_zipette_tiers":
var tiers []PointsTier
if err := json.Unmarshal([]byte(value), &tiers); err == nil {
settings.PointsZipetteTiers = tiers
}
case "points_total_tiers":
var tiers []PointsTier
if err := json.Unmarshal([]byte(value), &tiers); err == nil {
settings.PointsTotalTiers = tiers
}
case "referral_enabled":
settings.ReferralEnabled = value == "true"
case "delivery_schedule":
var sched DeliverySchedule
if err := json.Unmarshal([]byte(value), &sched); err == nil {
settings.DeliverySchedule = sched
}
case "postal_zones":
var zones []PostalZone
if err := json.Unmarshal([]byte(value), &zones); err == nil {
settings.PostalZones = zones
}
}
}
return settings, rows.Err()
}
// UpdateSettings sauvegarde les paramètres dans la DB
func (d *Database) UpdateSettings(s AppSettings) error {
boolStr := func(b bool) string {
if b {
return "true"
}
return "false"
}
if s.PointsCategoriesWeed == nil {
s.PointsCategoriesWeed = []string{}
}
if s.PointsCategoriesZipette == nil {
s.PointsCategoriesZipette = []string{}
}
if s.PointsCategoriesTotal == nil {
s.PointsCategoriesTotal = []string{}
}
weedJSON, err := json.Marshal(s.PointsCategoriesWeed)
if err != nil {
return fmt.Errorf("erreur sérialisation weed: %w", err)
}
zipetteJSON, err := json.Marshal(s.PointsCategoriesZipette)
if err != nil {
return fmt.Errorf("erreur sérialisation zipette: %w", err)
}
totalJSON, err := json.Marshal(s.PointsCategoriesTotal)
if err != nil {
return fmt.Errorf("erreur sérialisation total: %w", err)
}
tx, err := d.Begin()
if err != nil {
return err
}
defer tx.Rollback()
upsert := `INSERT INTO app_settings (key, value) VALUES ($1, $2)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`
if s.PointsWeedTiers == nil {
s.PointsWeedTiers = []PointsTier{}
}
if s.PointsZipetteTiers == nil {
s.PointsZipetteTiers = []PointsTier{}
}
if s.PointsTotalTiers == nil {
s.PointsTotalTiers = []PointsTier{}
}
weedTiersJSON, err := json.Marshal(s.PointsWeedTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation weed tiers: %w", err)
}
zipetteTiersJSON, err := json.Marshal(s.PointsZipetteTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation zipette tiers: %w", err)
}
totalTiersJSON, err := json.Marshal(s.PointsTotalTiers)
if err != nil {
return fmt.Errorf("erreur sérialisation total tiers: %w", err)
}
pairs := [][2]string{
{"penalties_enabled", boolStr(s.PenaltiesEnabled)},
{"show_amende_score", boolStr(s.ShowAmendeScore)},
{"points_enabled", boolStr(s.PointsEnabled)},
{"points_categories_weed", string(weedJSON)},
{"points_categories_zipette", string(zipetteJSON)},
{"points_categories_total", string(totalJSON)},
{"points_separated", boolStr(s.PointsSeparated)},
{"points_weed_tiers", string(weedTiersJSON)},
{"points_zipette_tiers", string(zipetteTiersJSON)},
{"points_total_tiers", string(totalTiersJSON)},
{"referral_enabled", boolStr(s.ReferralEnabled)},
}
schedJSON, err := json.Marshal(s.DeliverySchedule)
if err != nil {
return fmt.Errorf("erreur sérialisation delivery_schedule: %w", err)
}
pairs = append(pairs, [2]string{"delivery_schedule", string(schedJSON)})
if s.PostalZones == nil {
s.PostalZones = []PostalZone{}
}
zonesJSON, err := json.Marshal(s.PostalZones)
if err != nil {
return fmt.Errorf("erreur sérialisation postal_zones: %w", err)
}
pairs = append(pairs, [2]string{"postal_zones", string(zonesJSON)})
for _, p := range pairs {
if _, err = tx.Exec(upsert, p[0], p[1]); err != nil {
return fmt.Errorf("erreur upsert %s: %w", p[0], err)
}
}
return tx.Commit()
}
+3 -3
View File
@@ -11,7 +11,7 @@ func AddAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
@@ -37,7 +37,7 @@ func DeleteAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
@@ -61,7 +61,7 @@ func DeleteAddress(c *gin.Context) {
func GetAllAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
if userRole != "admin" && userRole != "cabine" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux administrateurs"})
return
}
+8 -1
View File
@@ -22,8 +22,15 @@ func AlertPolice(c *gin.Context) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"})
return
}
var req struct {
Message string `json:"message"`
}
// message optionnel — on ignore l'erreur de bind
_ = c.ShouldBindJSON(&req)
usernameStr := username.(string)
alert, err := database.CreateAlert(usernameStr)
alert, err := database.CreateAlert(usernameStr, req.Message)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+1
View File
@@ -738,6 +738,7 @@ func GetAllClients(c *gin.Context) {
"amende": cl.Amende,
"cancellations_count": cl.CancellationsCount,
"last_penalty_reason": cl.LastPenaltyReason,
"referral_balance": cl.ReferralBalance,
})
}
+3 -3
View File
@@ -528,8 +528,8 @@ func AddDeliverySupport(c *gin.Context) {
err = database.AddCommandLog(
commandID,
"support",
fmt.Sprintf("Support cabine: %s", req.Message),
"note",
fmt.Sprintf("Note cabine: %s", req.Message),
cabineUsername.(string),
)
@@ -630,7 +630,7 @@ func ForceValidateDelivery(c *gin.Context) {
return
}
validStatuses := []string{"assigned", "in_transit", "en_route", "en_cours", "support", "pending", "priority"}
validStatuses := []string{"assigned", "in_transit", "en_route", "en_cours", "pending", "priority"}
isValidStatus := false
for _, vs := range validStatuses {
if status == vs {
+136
View File
@@ -0,0 +1,136 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
// GET /api/v1/categories — public
func GetCategories(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
categories, err := database.GetAllCategories()
if err != nil {
log.Printf("❌ [CATEGORIES] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération catégories"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"categories": categories,
})
}
// POST /api/v2/admin/protected/categories — admin
func CreateCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if len(name) < 2 || len(name) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
return
}
if err := db.ValidateCategoryColor(req.Color); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
category, err := database.CreateCategory(name, req.Color, req.IsComingSoon)
if err != nil {
log.Printf("❌ [CATEGORIES] Création erreur: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Cette catégorie existe déjà"})
return
}
log.Printf("✅ [CATEGORIES] Créée: %s (couleur: %s)", name, category.Color)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"category": category,
})
}
// PUT /api/v2/admin/protected/categories/:id — admin
func UpdateCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
IsComingSoon bool `json:"is_coming_soon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Nom de catégorie requis"})
return
}
name := strings.ToLower(strings.TrimSpace(req.Name))
if len(name) < 2 || len(name) > 100 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Le nom doit faire entre 2 et 100 caractères"})
return
}
if err := db.ValidateCategoryColor(req.Color); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
category, err := database.UpdateCategory(id, name, req.Color, req.IsComingSoon)
if err != nil {
log.Printf("❌ [CATEGORIES] Mise à jour erreur: %v", err)
c.JSON(http.StatusConflict, gin.H{"error": "Ce nom existe déjà ou catégorie introuvable"})
return
}
log.Printf("✅ [CATEGORIES] Mise à jour: %d → %s (couleur: %s)", id, name, category.Color)
c.JSON(http.StatusOK, gin.H{
"success": true,
"category": category,
})
}
// DELETE /api/v2/admin/protected/categories/:id — admin
func DeleteCategory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
if err := database.DeleteCategory(id); err != nil {
log.Printf("❌ [CATEGORIES] Suppression erreur: %v", err)
if strings.Contains(err.Error(), "utilisée par") {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
} else {
c.JSON(http.StatusNotFound, gin.H{"error": "Catégorie non trouvée"})
}
return
}
log.Printf("✅ [CATEGORIES] Supprimée: %d", id)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "Catégorie supprimée"})
}
@@ -1,8 +1,3 @@
// ============================================
// handlers/client_tracking.go - NOUVEAU FICHIER
// SUIVI COMMANDE POUR CLIENTS
// ============================================
package handlers
import (
@@ -208,7 +203,6 @@ func getStatusMessage(status string) string {
messages := map[string]string{
"pending": "⏳ En attente d'assignation",
"assigned": "✅ Livreur assigné",
"support": "👨‍💼 En préparation",
"en_route": "🚗 En cours de livraison",
"arrived": "📍 Livreur arrivé",
"livre": "📦 Livré - En attente de confirmation",
@@ -250,7 +244,6 @@ func getStatusIcon(status string) string {
icons := map[string]string{
"created": "🛒",
"assigned": "👤",
"support": "📦",
"en_route": "🚗",
"arrived": "📍",
"livre": "✅",
+24 -352
View File
@@ -1,8 +1,3 @@
// ============================================
// handlers/commands_handlers_CORRIGES.go
// ============================================
// ⚠️ CreateCommandFromBasket SUPPRIMÉ (utiliser ValidateBasket à la place)
package handlers
import (
@@ -12,6 +7,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -19,14 +15,17 @@ import (
var (
rateLimitMap = make(map[string][]time.Time)
rateLimitMu sync.Mutex
maxRequests = 10
timeWindow = time.Minute
)
func checkRateLimit(key string) bool {
rateLimitMu.Lock()
defer rateLimitMu.Unlock()
now := time.Now()
if timestamps, exists := rateLimitMap[key]; exists {
// Nettoyer les anciennes entrées
var validTimestamps []time.Time
for _, ts := range timestamps {
if now.Sub(ts) < timeWindow {
@@ -399,7 +398,7 @@ func ApproveDelivery(c *gin.Context) {
// ✅ TRANSACTION ATOMIQUE dans la DB pour éviter race condition
// Cette fonction doit être créée dans le fichier db
totalPoints, err := database.ApproveDeliveryAtomic(commandID, username)
totalPoints, pointCategory, err := database.ApproveDeliveryAtomic(commandID, username)
if err != nil {
log.Printf("❌ [APPROVE] Erreur: %v", err)
// ❌ Ne pas exposer les détails de l'erreur
@@ -409,13 +408,14 @@ func ApproveDelivery(c *gin.Context) {
return
}
log.Printf("✅ [APPROVE] %d points attribués à %s", totalPoints, username)
log.Printf("✅ [APPROVE] %d points attribués à %s (catégorie: %s)", totalPoints, username, pointCategory)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Livraison confirmée",
"command_id": commandID,
"points_earned": totalPoints,
"category": pointCategory,
})
}
@@ -446,14 +446,14 @@ func StaffApproveDelivery(c *gin.Context) {
log.Printf("✅ [STAFF_APPROVE] %s (%s) confirme réception cmd %d", staffUsername, role, commandID)
totalPoints, clientUsername, err := database.ApproveDeliveryAtomicByStaff(commandID, staffUsername)
totalPoints, pointCategory, clientUsername, err := database.ApproveDeliveryAtomicByStaff(commandID, staffUsername)
if err != nil {
log.Printf("❌ [STAFF_APPROVE] Erreur: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Impossible de confirmer la réception: " + err.Error()})
return
}
log.Printf("✅ [STAFF_APPROVE] %d points attribués au client %s", totalPoints, clientUsername)
log.Printf("✅ [STAFF_APPROVE] %d points attribués au client %s (catégorie: %s)", totalPoints, clientUsername, pointCategory)
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -461,6 +461,7 @@ func StaffApproveDelivery(c *gin.Context) {
"command_id": commandID,
"client_username": clientUsername,
"points_earned": totalPoints,
"category": pointCategory,
})
}
@@ -542,7 +543,7 @@ func ValidateDelivery(c *gin.Context) {
currentStatus, _ := command["status"].(string)
validStatuses := []string{"assigned", "en_route", "pending", "support", "livre"}
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
isValid := false
for _, s := range validStatuses {
if currentStatus == s {
@@ -674,7 +675,7 @@ func AssignDeliveryPerson(c *gin.Context) {
return
}
database.AddCommandLog(commandID, "support",
database.AddCommandLog(commandID, "assigned",
fmt.Sprintf("Livreur '%s' assigné manuellement par %s", livreurUsername, staffUsername),
staffUsername.(string))
@@ -689,131 +690,6 @@ func AssignDeliveryPerson(c *gin.Context) {
})
}
// DisableCommands désactive une ou plusieurs commandes
// POST /api/v1/admin/commands/disable
func DisableCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req struct {
CommandIDs []int `json:"command_ids" binding:"required"`
Reason string `json:"reason"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Données invalides",
"details": err.Error(),
})
return
}
if len(req.CommandIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Aucun ID de commande fourni",
})
return
}
adminUsername, exists := c.Get("username")
if !exists {
adminUsername = "admin"
}
log.Printf("❌ [DISABLE] Désactivation de %d commande(s) par %s", len(req.CommandIDs), adminUsername)
disabledCount := 0
failedCount := 0
errors := []string{}
for _, commandID := range req.CommandIDs {
command, err := database.GetCommandByID(commandID)
if err != nil {
errors = append(errors, "Commande "+strconv.Itoa(commandID)+" non trouvée")
failedCount++
continue
}
currentStatus := command["status"].(string)
if currentStatus == "disabled" {
errors = append(errors, "Commande "+strconv.Itoa(commandID)+" déjà désactivée")
failedCount++
continue
}
err = database.UpdateCommandStatus(commandID, "disabled")
if err != nil {
errors = append(errors, "Erreur désactivation cmd "+strconv.Itoa(commandID))
failedCount++
continue
}
reason := req.Reason
if reason == "" {
reason = "Désactivée par admin"
}
database.AddCommandLog(commandID, "disabled", reason, adminUsername.(string))
disabledCount++
log.Printf("✅ [DISABLE] Commande %d désactivée", commandID)
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Traitement des commandes terminé",
"disabled_count": disabledCount,
"failed_count": failedCount,
"errors": errors,
})
}
// ============================================
// COMMANDES CLIENT (My Orders)
// ============================================
// GetMyCommands récupère les commandes du client authentifié
// GET /api/v1/my-commands?status=pending
func GetMyCommands(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// ✅ SÉCURITÉ: Récupérer depuis JWT validé
username, exists := c.Get("username")
if !exists {
log.Printf("❌ [MY_CMDS] Utilisateur non authentifié")
c.JSON(http.StatusUnauthorized, gin.H{
"error": "Utilisateur non authentifié",
})
return
}
usernameStr := username.(string)
status := c.Query("status")
// ✅ NOUVEAU: Paramètre pour exclure les commandes approved
excludeApproved := c.Query("exclude_approved") == "true"
log.Printf("📋 [MY_CMDS] Récupération pour %s (status=%s, exclude_approved=%v)",
usernameStr, status, excludeApproved)
// ✅ Utiliser la nouvelle fonction avec filtrage
commands, err := database.GetCommandsWithFilter(status, usernameStr, excludeApproved)
if err != nil {
log.Printf("❌ [MY_CMDS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération des commandes",
"details": err.Error(),
})
return
}
log.Printf("✅ [MY_CMDS] Trouvées: %d commandes", len(commands))
c.JSON(http.StatusOK, gin.H{
"success": true,
"commands": commands,
"count": len(commands),
})
}
func GetClientCommandsHistory(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
@@ -924,9 +800,9 @@ func NotifyClientToDescend(c *gin.Context) {
log.Printf("🔔 [NOTIFY] Client %s notifié pour commande %d par %s", clientUsername, commandID, staffUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Client notifié",
"client_username": clientUsername,
"success": true,
"message": "Client notifié",
"client_username": clientUsername,
})
}
@@ -989,12 +865,13 @@ func ShowItems(c *gin.Context) {
log.Printf("✅ [ITEMS] %d items récupérés", len(items))
commandInfo := map[string]interface{}{
"id": items[0]["command_id"],
"status": items[0]["command_status"],
"address": items[0]["command_address"],
"total_prix": items[0]["total_prix"],
"livreur": items[0]["livreur_assign"],
"created_at": items[0]["command_created_at"],
"id": items[0]["command_id"],
"status": items[0]["command_status"],
"address": items[0]["command_address"],
"total_prix": items[0]["total_prix"],
"referral_used": items[0]["referral_used"],
"livreur": items[0]["livreur_assign"],
"created_at": items[0]["command_created_at"],
}
clientInfo := map[string]interface{}{
@@ -1098,90 +975,6 @@ func GetCommandItemsWithDetails(c *gin.Context) {
})
}
// GetClientCommandsItems récupère tous les items du client
// GET /api/v1/clients/:username/commands/items
func GetClientCommandsItems(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username := c.Param("username")
if username == "" {
log.Printf("❌ [CLIENT_ITEMS] Username manquant")
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
log.Printf("📦 [CLIENT_ITEMS] Récupération pour: %s", username)
client, err := database.GetClientByUsername(username)
if err != nil {
log.Printf("❌ [CLIENT_ITEMS] Client non trouvé")
c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"})
return
}
items, err := database.GetCommandItemsByUsername(username)
if err != nil {
log.Printf("❌ [CLIENT_ITEMS] Erreur: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur lors de la récupération des items",
"details": err.Error(),
})
return
}
commandsMap := make(map[int][]map[string]interface{})
var commandIDs []int
for _, item := range items {
cmdID := int(item["command_id"].(float64))
if _, exists := commandsMap[cmdID]; !exists {
commandIDs = append(commandIDs, cmdID)
}
commandsMap[cmdID] = append(commandsMap[cmdID], item)
}
log.Printf("✅ [CLIENT_ITEMS] %d items dans %d commandes", len(items), len(commandsMap))
type CommandGroup struct {
CommandID int `json:"command_id"`
Status string `json:"status"`
Address string `json:"address"`
TotalPrice float64 `json:"total_price"`
LivreurAssign string `json:"livreur_assign"`
CreatedAt string `json:"created_at"`
ItemsCount int `json:"items_count"`
Items []map[string]interface{} `json:"items"`
}
var commandGroups []CommandGroup
for _, cmdID := range commandIDs {
items := commandsMap[cmdID]
if len(items) > 0 {
group := CommandGroup{
CommandID: cmdID,
Status: items[0]["command_status"].(string),
Address: items[0]["command_address"].(string),
TotalPrice: items[0]["total_prix"].(float64),
LivreurAssign: fmt.Sprintf("%v", items[0]["livreur_assign"]),
CreatedAt: items[0]["command_created_at"].(string),
ItemsCount: len(items),
Items: items,
}
commandGroups = append(commandGroups, group)
}
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"client": client.Username,
"client_name": fmt.Sprintf("%s %s", client.Prenom, client.Nom),
"phone": client.Telephone,
"total_items": len(items),
"total_commands": len(commandGroups),
"commands": commandGroups,
})
}
// UpdateItemStatus met à jour le statut d'un item
// PUT /api/v1/admin/items/:item_id/status
func UpdateItemStatus(c *gin.Context) {
@@ -1209,7 +1002,7 @@ func UpdateItemStatus(c *gin.Context) {
log.Printf("📝 [UPD_ITEM] Mise à jour: item=%d, status=%s", itemID, req.Status)
validStatuses := []string{"pending", "preparing", "ready", "shipped", "delivered"}
validStatuses := []string{"pending", "preparing", "delivered"}
isValid := false
for _, vs := range validStatuses {
if req.Status == vs {
@@ -1248,65 +1041,6 @@ func UpdateItemStatus(c *gin.Context) {
})
}
// GetCommandFullDetails récupère tous les détails d'une commande
// GET /api/v1/admin/commands/:id/full-details
func GetCommandFullDetails(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
log.Printf("❌ [FULL_DETAILS] Accès refusé - role=%s", userRole)
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé - Admin uniquement"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
log.Printf("📋 [FULL_DETAILS] Récupération complète: cmd %d", commandID)
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("❌ [FULL_DETAILS] Non trouvée")
c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"})
return
}
items, _ := database.GetCommandItems(commandID)
logs, _ := database.GetCommandLogs(commandID)
var clientFullInfo map[string]interface{}
if username, ok := command["username"].(string); ok {
if client, err := database.GetClientByUsername(username); err == nil {
clientFullInfo = map[string]interface{}{
"username": client.Username,
"nom": client.Nom,
"prenom": client.Prenom,
"telephone": client.Telephone,
"commands": client.Command,
"points": client.Point,
"penalties": client.Amende,
"created_at": client.CreatedAt,
}
}
}
log.Printf("✅ [FULL_DETAILS] Récupéré: %d items, %d logs", len(items), len(logs))
c.JSON(http.StatusOK, gin.H{
"success": true,
"command": command,
"client_info": clientFullInfo,
"items": items,
"items_count": len(items),
"logs": logs,
"logs_count": len(logs),
})
}
// DeleteCommandItem supprime un item d'une commande
// DELETE /api/v2/admin/protected/orders/:id/items/:item_id
func DeleteCommandItem(c *gin.Context) {
@@ -1404,65 +1138,3 @@ func UpdateCommandStatusAdmin(c *gin.Context) {
"new_status": req.Status,
})
}
// GetCommandItemsStats récupère les stats d'une commande
// GET /api/v1/commands/:id/stats
func GetCommandItemsStats(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
log.Printf("📊 [STATS] Calcul stats: cmd %d", commandID)
items, err := database.GetCommandItems(commandID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(items) == 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"items_count": 0,
"total_items": 0,
"total_price": 0,
"avg_price": 0,
})
return
}
totalItems := 0
totalPrice := 0.0
statusCounts := make(map[string]int)
for _, item := range items {
quantite := int(item["quantite"].(float64))
prix := item["prix"].(float64)
status := item["status"].(string)
totalItems += quantite
totalPrice += prix * float64(quantite)
statusCounts[status]++
}
avgPrice := 0.0
if len(items) > 0 {
avgPrice = totalPrice / float64(len(items))
}
log.Printf("✅ [STATS] Items=%d, Total=%.2f€", totalItems, totalPrice)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"items_count": len(items),
"total_items": totalItems,
"total_price": totalPrice,
"avg_price": avgPrice,
"status_breakdown": statusCounts,
})
}
+18 -14
View File
@@ -158,15 +158,16 @@ func GetDeliveryDetails(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true,
"delivery": gin.H{
"id": command["id"],
"status": command["status"],
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"created_at": command["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
"id": command["id"],
"status": command["status"],
"adresse": command["adresse"],
"total_prix": command["total_prix"],
"referral_used": command["referral_used"],
"created_at": command["created_at"],
"client_info": clientInfo,
"items": itemsSummary,
"items_count": len(items),
"eta": etaData,
},
})
}
@@ -226,8 +227,7 @@ func UpdateDeliveryStatus(c *gin.Context) {
// ✅ STATUTS VALIDES POUR LIVREUR (correspondant à la DB)
validStatuses := []string{
"support", // Prise en charge
"assigned", // Assigné (si auto-assignation)
"assigned", // Assigné
"en_route", // En route vers le client
"arrived", // Arrivé à destination
"livre", // Livré (en attente confirmation client)
@@ -364,8 +364,6 @@ func UpdateDeliveryStatus(c *gin.Context) {
if clientUsername != "" {
var clientMsg string
switch req.Status {
case "support":
clientMsg = fmt.Sprintf("Votre commande #%d est prise en charge", commandID)
case "en_route":
if etaMinutes > 0 {
clientMsg = fmt.Sprintf("Votre commande #%d est en route ! Arrivée dans ~%d min", commandID, etaMinutes)
@@ -378,6 +376,8 @@ func UpdateDeliveryStatus(c *gin.Context) {
clientMsg = fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID)
case "failed":
clientMsg = fmt.Sprintf("Échec de livraison pour la commande #%d", commandID)
case "cancelled":
clientMsg = fmt.Sprintf("Votre commande #%d a été annulée par le livreur", commandID)
}
if clientMsg != "" {
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
@@ -404,6 +404,11 @@ func UpdateDeliveryStatus(c *gin.Context) {
usernameStr,
)
case "cancelled":
// Annulation par le livreur - Nettoyer la queue
log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID)
database.CompleteDeliveryAndProcessNext(usernameStr, commandID)
case "arrived":
log.Printf("📍 Livreur arrivé à destination - Commande %d", commandID)
}
@@ -452,7 +457,6 @@ func degreesToRadians(degrees float64) float64 {
func getDeliveryStatusMessage(status string) string {
messages := map[string]string{
"support": "Prise en charge de la livraison",
"assigned": "Commande assignée",
"en_route": "En route vers le client",
"arrived": "Arrivé à destination",
+6 -15
View File
@@ -106,28 +106,19 @@ func GetOrderETA(c *gin.Context) {
return
}
// ✅ CORRECTION CRITIQUE: Vérifier si le livreur a démarré
if cmdStatus != "en_route" && cmdStatus != "arrived" {
log.Printf("⏳ [ETA] Commande en statut '%s' - ETA pas encore disponible", cmdStatus)
livreurAssign, _ := command["livreur_assign"].(string)
var livreurInfo string
if livreurAssign != "" {
livreurInfo = fmt.Sprintf("Livreur %s assigné", livreurAssign)
} else {
livreurInfo = "En attente d'assignation"
}
// Pour pending: aucune estimation disponible
if cmdStatus == "pending" {
log.Printf("⏳ [ETA] Commande en attente d'assignation - pas d'ETA")
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"status": cmdStatus,
"message": "Le livreur n'a pas encore démarré la livraison",
"eta_available": false,
"info": livreurInfo,
"message": "En attente d'assignation d'un livreur",
})
return
}
// Pour assigned/en_route/arrived: calcul ETA réel via position du livreur
// 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA
etaKey := fmt.Sprintf("command:eta:%d", commandID)
@@ -140,7 +131,7 @@ func GetOrderETA(c *gin.Context) {
fmt.Sscanf(updatedAtStr, "%d", &updatedAt)
timeSinceUpdate := time.Since(time.Unix(updatedAt, 0))
if timeSinceUpdate < 2*time.Minute {
if timeSinceUpdate < 30*time.Second {
// Cache valide
var etaMinutes int64
if etaStr, ok := etaData["eta_minutes"]; ok {
+86
View File
@@ -245,6 +245,92 @@ func MarkLivreurNotificationsRead(c *gin.Context) {
})
}
// RegisterAdminPushToken enregistre le push token d'un admin
// POST /api/v2/admin/protected/push-token
func RegisterAdminPushToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
var req struct {
PushToken string `json:"push_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "push_token requis"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.SaveUserPushToken(username, req.PushToken); err != nil {
log.Printf("❌ [ADMIN_PUSH_TOKEN] Erreur sauvegarde pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"})
return
}
log.Printf("✅ [ADMIN_PUSH_TOKEN] Token enregistré pour admin %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UnregisterAdminPushToken supprime le push token d'un admin (au logout)
// DELETE /api/v2/admin/protected/push-token
func UnregisterAdminPushToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.DeleteUserPushToken(username); err != nil {
log.Printf("❌ [ADMIN_PUSH_TOKEN] Erreur suppression pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"})
return
}
log.Printf("✅ [ADMIN_PUSH_TOKEN] Token supprimé pour admin %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// RegisterCabinePushToken enregistre le push token d'un agent cabine
// POST /api/v1/cabine/push-token
func RegisterCabinePushToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
var req struct {
PushToken string `json:"push_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "push_token requis"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.SaveUserPushToken(username, req.PushToken); err != nil {
log.Printf("❌ [CABINE_PUSH_TOKEN] Erreur sauvegarde pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"})
return
}
log.Printf("✅ [CABINE_PUSH_TOKEN] Token enregistré pour cabine %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UnregisterCabinePushToken supprime le push token d'un agent cabine (au logout)
// DELETE /api/v1/cabine/push-token
func UnregisterCabinePushToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.DeleteUserPushToken(username); err != nil {
log.Printf("❌ [CABINE_PUSH_TOKEN] Erreur suppression pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"})
return
}
log.Printf("✅ [CABINE_PUSH_TOKEN] Token supprimé pour cabine %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// MarkNotificationsRead marque toutes les notifications comme lues
// POST /api/v1/notifications/read
func MarkNotificationsRead(c *gin.Context) {
+70 -10
View File
@@ -319,7 +319,8 @@ func ValidateBasket(c *gin.Context) {
usernameStr := username.(string)
var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"`
DeliveryAddress string `json:"delivery_address" binding:"required"`
UseReferralBalance bool `json:"use_referral_balance"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"})
@@ -363,7 +364,16 @@ func ValidateBasket(c *gin.Context) {
}
}
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal)
// Récupérer les paramètres globaux (zones + parrainage)
appSettings, _ := database.GetSettings()
// Récupérer le solde parrainage disponible (seulement si le système est activé)
var referralBalance float64
if req.UseReferralBalance && appSettings.ReferralEnabled {
referralBalance, _ = database.GetClientReferralBalance(usernameStr)
}
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal, appSettings.PostalZones)
if !zoneResult.OK {
if zoneResult.ZoneName == "inconnue" {
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
@@ -379,18 +389,41 @@ func ValidateBasket(c *gin.Context) {
} else {
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": zoneResult.MinAmount,
"cart_total": cartTotal,
"missing": zoneResult.MinAmount - cartTotal,
"postal_code": zoneResult.PostalCode,
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": zoneResult.MinAmount,
"cart_total": cartTotal,
"missing": zoneResult.MinAmount - cartTotal,
"postal_code": zoneResult.PostalCode,
"referral_balance": referralBalance,
})
}
return
}
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount)
// Règle parrainage : après déduction du crédit, le client doit toujours payer au minimum le seuil de zone.
// Ex : zone 50€, crédit 50€ → panier doit être >= 100€
var referralUsed float64
if req.UseReferralBalance && referralBalance > 0 {
effectivePayment := cartTotal - referralBalance
if effectivePayment < zoneResult.MinAmount {
needed := zoneResult.MinAmount + referralBalance
log.Printf("❌ [CHECKOUT] Crédit parrainage %.2f€ mais panier insuffisant: %.2f€ < %.2f€ requis", referralBalance, cartTotal, needed)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Avec %.2f€ de crédit parrainage, votre commande doit atteindre %.2f€ (minimum zone %.0f€ + crédit utilisé)", referralBalance, needed, zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": needed,
"cart_total": cartTotal,
"missing": needed - cartTotal,
"referral_balance": referralBalance,
"postal_code": zoneResult.PostalCode,
})
return
}
referralUsed = referralBalance
}
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€, crédit parrainage utilisé: %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount, referralUsed)
// ============================================
// 2️⃣ Créer la commande (qui décrémente automatiquement le stock)
@@ -404,6 +437,27 @@ func ValidateBasket(c *gin.Context) {
commandID := command.ID
log.Printf("✅ [CHECKOUT] Commande %d créée", commandID)
// Débiter le solde parrainage si utilisé
if referralUsed > 0 {
tx, txErr := database.Begin()
if txErr == nil {
if txErr = database.UseClientReferralBalance(tx, usernameStr, referralUsed); txErr != nil {
tx.Rollback()
log.Printf("⚠️ [CHECKOUT] Impossible de débiter le crédit parrainage: %v", txErr)
} else {
tx.Commit()
log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr)
// Stocker le montant de parrainage sur la commande
if err := database.SetCommandReferralUsed(commandID, referralUsed); err != nil {
log.Printf("⚠️ [CHECKOUT] Impossible de sauvegarder referral_used sur commande: %v", err)
}
}
}
}
// Notifier immédiatement tous les admins et agents cabine
go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress)
// ============================================
// 3️⃣ Vider le panier
// ============================================
@@ -481,12 +535,15 @@ func ValidateBasket(c *gin.Context) {
// Notifier le livreur de la nouvelle commande
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
if referralUsed > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed)
}
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
}
// Notifier le client
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime)
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Un livreur est en route.", commandID)
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
assigned = true
@@ -510,11 +567,14 @@ func ValidateBasket(c *gin.Context) {
// ============================================
// 5️⃣ Réponse
// ============================================
newBalance, _ := database.GetClientReferralBalance(usernameStr)
resp := gin.H{
"success": true,
"command_id": commandID,
"delivery_address": req.DeliveryAddress,
"status": "pending",
"referral_used": referralUsed,
"referral_balance": newBalance,
}
if assigned {
+14 -19
View File
@@ -130,23 +130,18 @@ func validateUnit(unit string) error {
return nil
}
func validateCategory(category string) error {
// Nettoyage
category = strings.ToLower(strings.TrimSpace(category))
category = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, category)
validCategories := []string{"weed&hash", "zipette&co", "gros&semi"}
for _, v := range validCategories {
if category == v {
return nil
}
func validateCategory(database *db.Database, category string) error {
if category == "" {
return fmt.Errorf("catégorie requise")
}
return fmt.Errorf("catégorie invalide")
exists, err := database.CategoryExists(category)
if err != nil {
return fmt.Errorf("erreur vérification catégorie")
}
if !exists {
return fmt.Errorf("catégorie invalide")
}
return nil
}
// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension)
@@ -246,7 +241,7 @@ func CreateProduct(c *gin.Context) {
return r
}, category)
if err := validateCategory(category); err != nil {
if err := validateCategory(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -511,7 +506,7 @@ func GetProductsByCategory(c *gin.Context) {
category := strings.ToLower(strings.TrimSpace(c.Param("category")))
// ✅ VALIDATION
if err := validateCategory(category); err != nil {
if err := validateCategory(database, category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"success": false,
"error": err.Error(),
@@ -627,7 +622,7 @@ func UpdateProduct(c *gin.Context) {
return
}
if err := validateCategory(updateData.Category); err != nil {
if err := validateCategory(database, updateData.Category); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/services"
"log"
"net/http"
"strconv"
@@ -178,6 +179,9 @@ func UpdateLivreurLocation(c *gin.Context) {
log.Printf("📍 Position GPS mise à jour pour %s: (%.6f, %.6f)",
usernameStr, req.Latitude, req.Longitude)
// ✅ Recalculer l'ETA en temps réel si livreur en_route
go refreshETAForActivDelivery(database, usernameStr, req.Latitude, req.Longitude)
// ✅ 2. Vérifier/Initialiser le statut du livreur
statusKey := fmt.Sprintf("delivery:status:%s", usernameStr)
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
@@ -1049,3 +1053,89 @@ func GetRealtimeStats(c *gin.Context) {
"stats": stats,
})
}
// ============================================
// RECALCUL ETA EN TEMPS RÉEL (appelé à chaque update GPS)
// ============================================
// refreshETAForActivDelivery recalcule l'ETA depuis la position actuelle du livreur.
// Appelé en goroutine à chaque mise à jour GPS (toutes les ~15s).
func refreshETAForActivDelivery(database *db.Database, username string, lat, lon float64) {
// 1. Récupérer le statut actuel du livreur
statusKey := fmt.Sprintf("delivery:status:%s", username)
statusData, err := db.Redis.Get(db.RedisCtx, statusKey).Result()
if err != nil || statusData == "" {
return
}
var status map[string]interface{}
if err := json.Unmarshal([]byte(statusData), &status); err != nil {
return
}
// 2. Seulement si en_route ou arrived
currentStatus, _ := status["status"].(string)
if currentStatus != "en_route" && currentStatus != "arrived" {
return
}
// 3. Récupérer la commande active
var commandID int
switch v := status["current_command"].(type) {
case float64:
commandID = int(v)
case int:
commandID = v
default:
return
}
if commandID <= 0 {
return
}
// 4. Récupérer les coordonnées destination depuis le cache Redis
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result()
if err != nil || destData == "" {
return
}
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.Unmarshal([]byte(destData), &coords); err != nil || coords.Lat == 0 {
return
}
// 5. Calculer l'ETA depuis la position GPS actuelle
from := services.Coordinates{Latitude: lat, Longitude: lon}
to := services.Coordinates{Latitude: coords.Lat, Longitude: coords.Lon}
etaMinutes, distanceKm, err := services.GetETAWithTraffic(from, to)
if err != nil {
// Fallback Haversine uniquement si TomTom indisponible
distanceKm = services.CalculateDistance(from, to)
etaMinutes = services.CalculateETA(distanceKm)
log.Printf("⚠️ [ETA_REALTIME] TomTom indisponible pour %s cmd %d, fallback: %.2fkm → %dmin",
username, commandID, distanceKm, etaMinutes)
} else {
log.Printf("🔄 [ETA_REALTIME] %s cmd %d recalculé: %.2fkm → %dmin (TomTom)",
username, commandID, distanceKm, etaMinutes)
}
// 6. Mettre à jour le cache Redis ETA (écrase l'ancien)
now := time.Now()
arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute)
etaKey := fmt.Sprintf("command:eta:%d", commandID)
db.Redis.HSet(db.RedisCtx, etaKey, map[string]interface{}{
"command_id": commandID,
"eta_minutes": etaMinutes,
"updated_at": now.Unix(),
"arrival_time": arrivalTime.Unix(),
"distance_km": distanceKm,
"with_traffic": err == nil,
})
db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour)
}
+79
View File
@@ -0,0 +1,79 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GetMyReferralBalance — GET /api/v1/referral/balance (client)
func GetMyReferralBalance(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Utilisateur non authentifié"})
return
}
settings, _ := database.GetSettings()
if !settings.ReferralEnabled {
c.JSON(http.StatusOK, gin.H{"balance": 0, "referral_enabled": false})
return
}
balance, err := database.GetClientReferralBalance(username.(string))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"balance": balance, "referral_enabled": true})
}
// CreditClientReferralAdmin — POST /api/v2/admin/protected/client/:username/referral/credit (admin)
func CreditClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
var req struct {
Amount float64 `json:"amount" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Amount <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "Montant invalide"})
return
}
if err := database.CreditClientReferral(targetUsername, req.Amount); err != nil {
log.Printf("❌ [REFERRAL] Crédit échoué pour %s: %v", targetUsername, err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
balance, _ := database.GetClientReferralBalance(targetUsername)
log.Printf("✅ [REFERRAL] +%.2f€ crédité à %s, nouveau solde: %.2f€", req.Amount, targetUsername, balance)
c.JSON(http.StatusOK, gin.H{
"message": "Solde parrainage crédité",
"balance": balance,
})
}
// GetClientReferralAdmin — GET /api/v2/admin/protected/client/:username/referral (admin)
func GetClientReferralAdmin(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
targetUsername := c.Param("username")
balance, err := database.GetClientReferralBalance(targetUsername)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"username": targetUsername,
"balance": balance,
})
}
+67
View File
@@ -0,0 +1,67 @@
package handlers
import (
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// GET /api/v1/app-settings — public, sans auth
// Retourne uniquement les flags visibles par clients/cabine (pas les détails de catégories)
func GetPublicSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
// En cas d'erreur, retourner les valeurs par défaut
settings = db.DefaultSettings()
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"penalties_enabled": settings.PenaltiesEnabled,
"show_amende_score": settings.ShowAmendeScore,
"points_enabled": settings.PointsEnabled,
"points_separated": settings.PointsSeparated,
"referral_enabled": settings.ReferralEnabled,
"delivery_schedule": settings.DeliverySchedule,
})
}
// GET /api/v2/admin/protected/settings
func GetSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
log.Printf("❌ [SETTINGS] Erreur lecture: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture paramètres"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "settings": settings})
}
// PUT /api/v2/admin/protected/settings
func UpdateSettings(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
var req db.AppSettings
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Paramètres invalides"})
return
}
if err := database.UpdateSettings(req); err != nil {
log.Printf("❌ [SETTINGS] Erreur mise à jour: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur mise à jour paramètres"})
return
}
log.Printf("✅ [SETTINGS] Mise à jour: penalties=%v, points_separated=%v, weed=%v, zipette=%v, total=%v",
req.PenaltiesEnabled, req.PointsSeparated, req.PointsCategoriesWeed, req.PointsCategoriesZipette, req.PointsCategoriesTotal)
c.JSON(http.StatusOK, gin.H{"success": true, "settings": req})
}
-572
View File
@@ -6,565 +6,9 @@ package handlers
import (
"encoding/json"
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"time"
"github.com/gin-gonic/gin"
)
// ============================================
// INCIDENTS TRAFFIC TOMTOM
// ============================================
// GetIncidentsAroundDeliveryPerson récupère les incidents autour d'un livreur
// GET /api/v2/admin/traffic/delivery/:username/incidents
func GetIncidentsAroundDeliveryPerson(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
username := c.Param("username")
if username == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"})
return
}
// Récupérer position du livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Position livreur non trouvée",
"details": err.Error(),
})
return
}
// Rayon de recherche par défaut: 5 km
radius := 5000 // mètres
// Récupérer incidents TomTom
incidents, err := fetchIncidents(lat, lon, radius)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération incidents",
"details": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"username": username,
"location": gin.H{"latitude": lat, "longitude": lon},
"radius_km": radius / 1000,
"incidents": incidents,
"count": len(incidents),
})
}
// GetIncidentsForAllDeliveries récupère incidents + routes pour tous livreurs actifs
// GET /api/v2/admin/traffic/incidents/all
func GetIncidentsForAllDeliveries(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// Récupérer tous les livreurs disponibles depuis Redis
livreurs, err := database.GetAvailableDeliveryPersonsRedis()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur récupération livreurs",
"details": err.Error(),
})
return
}
results := []gin.H{}
for _, livreur := range livreurs {
username := livreur.Username
status := livreur.Status
// Sauter les livreurs offline
if status == "offline" {
continue
}
// Position livreur
lat, lon, err := database.GetDeliveryPersonLocation(username)
if err != nil {
log.Printf("⚠️ Position non trouvée pour %s", username)
continue
}
// Vérifier s'il a une commande en cours
commandID := livreur.CurrentCommand
if commandID == 0 {
// Pas de livraison en cours
results = append(results, gin.H{
"username": username,
"status": status,
"location": gin.H{"latitude": lat, "longitude": lon},
"has_delivery": false,
"incidents": []gin.H{},
"route": nil,
})
continue
}
// Récupérer la commande
command, err := database.GetCommandByID(commandID)
if err != nil {
log.Printf("⚠️ Commande %d non trouvée", commandID)
continue
}
// Coordonnées destination
var destLat, destLon float64
if dLat, ok := getFloatFromMap(command, "dest_latitude"); ok && dLat != 0 {
destLat = dLat
}
if dLon, ok := getFloatFromMap(command, "dest_longitude"); ok && dLon != 0 {
destLon = dLon
}
// Si pas de coordonnées, géocoder
if destLat == 0 || destLon == 0 {
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
continue
}
geoService := c.MustGet("geoService").(*services.GeoService)
location, err := geoService.GeocodeAddress(address)
if err != nil {
log.Printf("⚠️ Géocodage échoué pour %s", address)
continue
}
destLat = location.Latitude
destLon = location.Longitude
}
// Récupérer incidents sur le trajet
incidents, _ := fetchIncidentsOnRoute(lat, lon, destLat, destLon)
// Convertir incidents en gin.H pour JSON
incidentsJSON := make([]gin.H, len(incidents))
for i, inc := range incidents {
incidentsJSON[i] = gin.H{
"type": inc.Type,
"icon": inc.Icon,
"description": inc.Description,
}
}
// Calculer route avec trafic
routeSummary, err := fetchRouteSummary(lat, lon, destLat, destLon)
if err != nil {
log.Printf("⚠️ Erreur calcul route pour %s", username)
routeSummary = models.RouteSummary{}
}
results = append(results, gin.H{
"username": username,
"status": status,
"location": gin.H{"latitude": lat, "longitude": lon},
"destination": gin.H{"latitude": destLat, "longitude": destLon},
"has_delivery": true,
"command_id": commandID,
"incidents": incidentsJSON,
"route": routeSummary,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"deliveries": results,
"count": len(results),
})
}
// ============================================
// MISE À JOUR ETA AVEC TRAFIC
// ============================================
func UpdateETAWithRealTraffic(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
userRole := c.GetString("role")
if userRole != "admin" && userRole != "livreur" {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
// Récupérer l'ID de la commande
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"})
return
}
// Récupérer la commande
command, err := database.GetCommandByID(commandID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Commande non trouvée",
"command_id": commandID,
})
return
}
// Vérifier qu'un livreur est assigné
livreurAssign, ok := command["livreur_assign"].(string)
if !ok || livreurAssign == "" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Aucun livreur assigné",
"command_id": commandID,
})
return
}
// Position actuelle du livreur
lat, lon, err := database.GetDeliveryPersonLocation(livreurAssign)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "Position livreur introuvable",
"livreur": livreurAssign,
"details": err.Error(),
})
return
}
var destLat, destLon float64
// 🔹 1. Tenter de récupérer depuis le cache Redis (clé spécifique pour destination)
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
destData, err := db.Redis.Get(db.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 && coords.Lat != 0 && coords.Lon != 0 {
destLat = coords.Lat
destLon = coords.Lon
log.Printf("📍 Destination trouvée dans cache Redis pour commande %d", commandID)
}
}
// 🔹 2. Fallback: récupérer depuis la DB
if destLat == 0 || destLon == 0 {
if dLat, okLat := getFloatFromMap(command, "dest_latitude"); okLat && dLat != 0 {
destLat = dLat
}
if dLon, okLon := getFloatFromMap(command, "dest_longitude"); okLon && dLon != 0 {
destLon = dLon
}
}
// 🔹 3. Si toujours pas de coordonnées, géocoder l'adresse
if destLat == 0 || destLon == 0 {
address, ok := command["adresse"].(string)
if !ok || address == "" || address == "Adresse non spécifiée" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse de destination manquante ou invalide",
"command_id": commandID,
})
return
}
geoService := c.MustGet("geoService").(*services.GeoService)
location, err := geoService.GeocodeAddress(address)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Impossible de géocoder l'adresse",
"address": address,
"command_id": commandID,
"details": err.Error(),
})
return
}
destLat = location.Latitude
destLon = location.Longitude
log.Printf("📍 Adresse géocodée pour commande %d: %s -> (%.6f, %.6f)",
commandID, address, destLat, destLon)
}
// 🔹 4. Sauvegarder les coordonnées destination dans le cache Redis
coordsJSON, _ := json.Marshal(map[string]float64{
"lat": destLat,
"lon": destLon,
})
if err := db.Redis.Set(db.RedisCtx, destCacheKey, coordsJSON, 4*time.Hour).Err(); err != nil {
log.Printf("⚠️ Impossible de sauvegarder destination dans Redis: %v", err)
}
// 🔹 5. Calculer le temps réel avec TomTom Routing API
routeSummary, err := fetchRouteSummary(lat, lon, destLat, destLon)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Impossible de calculer l'itinéraire",
"command_id": commandID,
"from": gin.H{"lat": lat, "lon": lon},
"to": gin.H{"lat": destLat, "lon": destLon},
"details": err.Error(),
})
return
}
// 🔹 6. Mettre à jour l'ETA dans Redis
err = database.SetCommandETA(commandID, routeSummary.TravelTimeInMinutes)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Erreur mise à jour ETA",
"command_id": commandID,
"details": err.Error(),
})
return
}
log.Printf("✅ ETA mis à jour pour commande %d: %d min (trafic réel inclus)",
commandID, routeSummary.TravelTimeInMinutes)
c.JSON(http.StatusOK, gin.H{
"success": true,
"command_id": commandID,
"eta_minutes": routeSummary.TravelTimeInMinutes,
"distance_km": routeSummary.LengthInKm,
"with_traffic": true,
"route_summary": routeSummary,
})
}
// ============================================
// FONCTIONS HELPERS - TOMTOM API
// ============================================
// fetchIncidents récupère les incidents de trafic autour d'une position
func fetchIncidents(lat, lon float64, radius int) ([]models.Incident, error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return nil, fmt.Errorf("TOMTOM_API_KEY non configurée")
}
// API TomTom Traffic Incidents
url := fmt.Sprintf(
"https://api.tomtom.com/traffic/services/5/incidentDetails?key=%s&bbox=%f,%f,%f,%f&fields={incidents{type,geometry{type,coordinates},properties{iconCategory,magnitudeOfDelay,events{description,code,iconCategory}}}}",
apiKey,
lon-0.05, lat-0.05, // Southwest corner
lon+0.05, lat+0.05, // Northeast corner
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, fmt.Errorf("erreur requête incidents: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API incidents error %d: %s", resp.StatusCode, string(body))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("erreur lecture réponse: %w", err)
}
var incidentResponse models.IncidentResponse
err = json.Unmarshal(body, &incidentResponse)
if err != nil {
return nil, fmt.Errorf("erreur parsing incidents: %w", err)
}
// Convertir en []models.Incident
incidents := make([]models.Incident, len(incidentResponse.Incidents))
for i, inc := range incidentResponse.Incidents {
incidents[i] = models.Incident{
Type: inc.Type,
Icon: inc.Icon,
Description: inc.Description,
}
}
return incidents, nil
}
// fetchIncidentsOnRoute récupère les incidents sur un trajet
func fetchIncidentsOnRoute(startLat, startLon, destLat, destLon float64) ([]models.Incident, error) {
// Calculer la bounding box du trajet
minLat := min(startLat, destLat) - 0.02
maxLat := max(startLat, destLat) + 0.02
minLon := min(startLon, destLon) - 0.02
maxLon := max(startLon, destLon) + 0.02
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return []models.Incident{}, nil
}
url := fmt.Sprintf(
"https://api.tomtom.com/traffic/services/5/incidentDetails?key=%s&bbox=%f,%f,%f,%f&fields={incidents{type,geometry{type,coordinates},properties{iconCategory,magnitudeOfDelay,events{description,code,iconCategory}}}}",
apiKey,
minLon, minLat,
maxLon, maxLat,
)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return []models.Incident{}, nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return []models.Incident{}, nil
}
body, _ := io.ReadAll(resp.Body)
var incidentResponse models.IncidentResponse
if err := json.Unmarshal(body, &incidentResponse); err != nil {
return []models.Incident{}, nil
}
// Convertir en []models.Incident
incidents := make([]models.Incident, len(incidentResponse.Incidents))
for i, inc := range incidentResponse.Incidents {
incidents[i] = models.Incident{
Type: inc.Type,
Icon: inc.Icon,
Description: inc.Description,
}
}
return incidents, nil
}
// récupère le temps de trajet réel via l'API TomTom Routing
// fetchRouteSummary récupère le temps de trajet réel via l'API TomTom Routing
func fetchRouteSummary(startLat, startLon, destLat, destLon float64) (models.RouteSummary, error) {
apiKey := os.Getenv("TOMTOM_API_KEY")
if apiKey == "" {
return models.RouteSummary{}, fmt.Errorf("TOMTOM_API_KEY non configurée")
}
// Validation des coordonnées
if startLat < -90 || startLat > 90 || destLat < -90 || destLat > 90 {
return models.RouteSummary{}, fmt.Errorf("latitude invalide: start=%.6f, dest=%.6f", startLat, destLat)
}
if startLon < -180 || startLon > 180 || destLon < -180 || destLon > 180 {
return models.RouteSummary{}, fmt.Errorf("longitude invalide: start=%.6f, dest=%.6f", startLon, destLon)
}
// API TomTom Routing: Calculate Route
url := fmt.Sprintf(
"https://api.tomtom.com/routing/1/calculateRoute/%f,%f:%f,%f/json?key=%s&traffic=true&travelMode=car",
startLat, startLon, destLat, destLon, apiKey,
)
log.Printf("🛣️ Appel TomTom: (%.6f,%.6f) -> (%.6f,%.6f)", startLat, startLon, destLat, destLon)
// Timeout réduit à 8 secondes
client := &http.Client{Timeout: 8 * time.Second}
resp, err := client.Get(url)
if err != nil {
// Fallback: estimation basée sur distance Haversine
distance := haversineDistance(startLat, startLon, destLat, destLon)
estimatedMinutes := int(distance/25*60) + 3 // ~25 km/h en ville + 3 min marge
if estimatedMinutes < 5 {
estimatedMinutes = 5
}
log.Printf("⚠️ TomTom timeout/erreur, fallback: %.2f km -> %d min estimé", distance, estimatedMinutes)
return models.RouteSummary{
TravelTimeInMinutes: estimatedMinutes,
LengthInKm: distance,
}, nil // Pas d'erreur, on retourne l'estimation
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.ReadAll(resp.Body) // Lire et ignorer le body pour fermer proprement
// Fallback en cas d'erreur API
distance := haversineDistance(startLat, startLon, destLat, destLon)
estimatedMinutes := int(distance/25*60) + 3
if estimatedMinutes < 5 {
estimatedMinutes = 5
}
log.Printf("⚠️ TomTom API error %d, fallback: %.2f km -> %d min", resp.StatusCode, distance, estimatedMinutes)
return models.RouteSummary{
TravelTimeInMinutes: estimatedMinutes,
LengthInKm: distance,
}, nil
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return models.RouteSummary{}, fmt.Errorf("erreur lecture réponse: %w", err)
}
var routeResponse models.RouteResponse
err = json.Unmarshal(body, &routeResponse)
if err != nil {
return models.RouteSummary{}, fmt.Errorf("erreur parsing routing: %w", err)
}
if len(routeResponse.Routes) == 0 {
return models.RouteSummary{}, fmt.Errorf("aucun itinéraire trouvé")
}
summary := routeResponse.Routes[0].Summary
summary.TravelTimeInMinutes = (summary.TravelTimeInSeconds + 59) / 60
summary.LengthInKm = float64(summary.LengthInMeters) / 1000.0
log.Printf("🛣️ Route calculée: %.2f km, %d min (trafic inclus)", summary.LengthInKm, summary.TravelTimeInMinutes)
return summary, nil
}
// haversineDistance calcule la distance en km entre deux points GPS
func haversineDistance(lat1, lon1, lat2, lon2 float64) float64 {
const R = 6371.0 // Rayon Terre en km
const toRad = math.Pi / 180.0
dLat := (lat2 - lat1) * toRad
dLon := (lon2 - lon1) * toRad
a := math.Sin(dLat/2)*math.Sin(dLat/2) +
math.Cos(lat1*toRad)*math.Cos(lat2*toRad)*
math.Sin(dLon/2)*math.Sin(dLon/2)
c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a))
return R * c
}
// getFloatFromMap récupère un float64 depuis une map avec différents types
func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
value, exists := m[key]
@@ -606,19 +50,3 @@ func getFloatFromMap(m map[string]interface{}, key string) (float64, bool) {
return 0, false
}
}
// min retourne le minimum entre deux float64
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}
// max retourne le maximum entre deux float64
func max(a, b float64) float64 {
if a > b {
return a
}
return b
}
@@ -1,11 +1,3 @@
// ============================================
// handlers/delivery_validation_handler.go - CLEAN VERSION
// VALIDATION LIVRAISON AVEC VÉRIFICATION PROXIMITÉ GPS
//
// ⚠️ IMPORTANT: Ce fichier contient UNIQUEMENT les fonctions livreur
// Les fonctions ADMIN sont dans cabine_handlers.go
// ============================================
package handlers
import (
@@ -358,11 +350,11 @@ func StartDelivery(c *gin.Context) {
// Vérifier le statut actuel
currentStatus, _ := command["status"].(string)
if currentStatus != "support" && currentStatus != "assigned" {
if currentStatus != "assigned" {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Impossible de démarrer cette livraison",
"current_status": currentStatus,
"message": "La commande doit être en statut 'support' ou 'assigned'",
"message": "La commande doit être en statut 'assigned'",
})
return
}
+11 -76
View File
@@ -1,66 +1,9 @@
package handlers
import "regexp"
// ============================================================
// Zones de livraison — minimum de commande par code postal
// ============================================================
// Remplis les listes de codes postaux quand tu les as.
// Un code postal absent de toutes les zones → commande refusée.
// ============================================================
type deliveryZone struct {
Name string
MinAmount float64
codes map[string]struct{}
}
var deliveryZones = []deliveryZone{
{
Name: "Zone 30€",
MinAmount: 30.0,
codes: postalSet([]string{
"44000",
"44100",
"44200",
"44300",
}),
},
{
Name: "Zone 50€",
MinAmount: 50.0,
codes: postalSet([]string{
"44400", // Rezé
"44880", // Les Sorinières / Sautron
"44120", // Vertou
"44230", // Saint-Sébastien-sur-Loire
"44115", // Basse-Goulaine / Haute-Goulaine
"44980", // Sainte-Luce-sur-Loire
"44470", // Carquefou
"44240", // La Chapelle-sur-Erdre
"44700", // Orvault
"44800", // Saint-Herblain
"44340", // Bouguenais
"44620", // La Montagne
"44830", // Bouaye
}),
},
{
Name: "Zone 100€",
MinAmount: 100.0,
codes: postalSet([]string{
"44860", // Pont-Saint-Martin / Saint-Aignan-Grandlieu
"44220", // Couëron
"44118", // La Chevrolière
"44830", // Brains
"44710", // Saint-Léger-les-Vignes
"44690", // La Haie-Fouassière
"44470", // Mauves-sur-Loire
"44240", // Sucé-sur-Erdre
"44119", // Grandchamp-des-Fontaines
}),
},
}
import (
"gestion/db"
"regexp"
)
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
@@ -91,21 +34,18 @@ type zoneCheckResult struct {
}
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
// Les zones sont lues depuis la DB (settings.PostalZones).
// Code postal introuvable → OK = false (refus).
// Code postal hors de toutes les zones → OK = false (refus).
func checkDeliveryZone(deliveryAddress string, total float64) zoneCheckResult {
func checkDeliveryZone(deliveryAddress string, total float64, zones []db.PostalZone) zoneCheckResult {
code := extractPostalCode(deliveryAddress)
if code == "" {
return zoneCheckResult{
PostalCode: "",
ZoneName: "inconnue",
MinAmount: 0,
OK: false,
}
return zoneCheckResult{PostalCode: "", ZoneName: "inconnue", MinAmount: 0, OK: false}
}
for _, zone := range deliveryZones {
if _, found := zone.codes[code]; found {
for _, zone := range zones {
set := postalSet(zone.Codes)
if _, found := set[code]; found {
return zoneCheckResult{
PostalCode: code,
ZoneName: zone.Name,
@@ -115,10 +55,5 @@ func checkDeliveryZone(deliveryAddress string, total float64) zoneCheckResult {
}
}
return zoneCheckResult{
PostalCode: code,
ZoneName: "hors zone",
MinAmount: 0,
OK: false,
}
return zoneCheckResult{PostalCode: code, ZoneName: "hors zone", MinAmount: 0, OK: false}
}
@@ -0,0 +1,83 @@
package middleware
import (
"gestion/db"
"log"
"net/http"
"github.com/gin-gonic/gin"
)
// BlockClientIfPenalty bloque le checkout si le client a une amende non payée.
// Lit d'abord les paramètres globaux (penalties_enabled), puis le PenaltyCache Redis, fallback DB.
func BlockClientIfPenalty(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
// Vérifier si les amendes sont activées dans les paramètres globaux
if settings, err := database.GetSettings(); err == nil && !settings.PenaltiesEnabled {
c.Next()
return
}
clientID, exists := c.Get("client_id")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
c.Abort()
return
}
// 1. Tenter le cache Redis via la session
if id, ok := clientID.(int); ok {
if session, err := database.GetClientSession(id); err == nil {
if session.PenaltyCache > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour client_id=%d (amende=%.2f via cache)", id, session.PenaltyCache)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commande bloquée : vous avez une amende en attente de paiement",
"amende": session.PenaltyCache,
"blocked": true,
})
c.Abort()
return
}
// Cache présent et amende = 0 → on laisse passer sans requête DB
c.Next()
return
}
}
// 2. Fallback DB si session Redis absente/expirée
username, exists := c.Get("username")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
c.Abort()
return
}
usernameStr, ok := username.(string)
if !ok || usernameStr == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Username invalide"})
c.Abort()
return
}
amende, err := database.GetClientAmende(usernameStr)
if err != nil {
log.Printf("❌ [PENALTY] Erreur vérification amende pour %s: %v", usernameStr, err)
// En cas d'erreur DB on laisse passer pour ne pas bloquer l'utilisateur injustement
c.Next()
return
}
if amende > 0 {
log.Printf("🚫 [PENALTY] Checkout bloqué pour %s (amende=%.2f via DB)", usernameStr, amende)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commande bloquée : vous avez une amende en attente de paiement",
"amende": amende,
"blocked": true,
})
c.Abort()
return
}
c.Next()
}
+54 -6
View File
@@ -1,6 +1,8 @@
package middleware
import (
"fmt"
"gestion/db"
"log"
"net/http"
"time"
@@ -14,21 +16,67 @@ func OrderHoursMiddleware(c *gin.Context) {
loc = time.UTC
}
now := time.Now().In(loc)
weekday := now.Weekday()
hour := now.Hour()
min := now.Minute()
// Récupérer le planning depuis les settings DB
database := c.MustGet("database").(*db.Database)
settings, err := database.GetSettings()
if err != nil {
log.Printf("⚠️ [CLOCK-MWARE] Erreur lecture settings: %v — accès autorisé par défaut", err)
c.Next()
return
}
sched := settings.DeliverySchedule
var day db.DaySchedule
switch weekday {
case time.Monday:
day = sched.Monday
case time.Tuesday:
day = sched.Tuesday
case time.Wednesday:
day = sched.Wednesday
case time.Thursday:
day = sched.Thursday
case time.Friday:
day = sched.Friday
case time.Saturday:
day = sched.Saturday
case time.Sunday:
day = sched.Sunday
}
if !day.Enabled {
log.Printf("❌ [CLOCK-MWARE] Commande refusée — jour fermé (%s)", weekday)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commandes non disponibles aujourd'hui",
"message": "La livraison n'est pas disponible ce jour",
"current_time": now.Format("15:04"),
})
c.Abort()
return
}
parseTime := func(t string) int {
var h, m int
fmt.Sscanf(t, "%d:%d", &h, &m)
return h*60 + m
}
currentMinutes := hour*60 + min
openMinutes := 13*60 + 55
closeMinutes := 23*60 + 30
openMinutes := parseTime(day.OpenTime)
closeMinutes := parseTime(day.CloseTime)
if currentMinutes < openMinutes || currentMinutes >= closeMinutes {
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: 13h55 - 23h30)", hour, min)
log.Printf("❌ [CLOCK-MWARE] Commande refusée à %02d:%02d (plage autorisée: %s - %s)", hour, min, day.OpenTime, day.CloseTime)
c.JSON(http.StatusForbidden, gin.H{
"error": "Commandes non disponibles à cette heure",
"message": "Vous pouvez commander entre 13h55 et 23h30",
"message": fmt.Sprintf("Vous pouvez commander entre %s et %s", day.OpenTime, day.CloseTime),
"current_time": now.Format("15:04"),
"open_at": "13:55",
"close_at": "23:30",
"open_at": day.OpenTime,
"close_at": day.CloseTime,
})
c.Abort()
return
+1
View File
@@ -4,6 +4,7 @@ type AlertPolicy struct {
ID int `json:"id"`
Username string `json:"username"`
Status string `json:"status"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
+2 -1
View File
@@ -14,9 +14,10 @@ type Client struct {
Point int `json:"point"`
PointZipette int `json:"points_zipette"`
Amende float64 `json:"amende"`
CancellationsCount int `json:"cancellations_count"` // ✅ NOUVEAU
CancellationsCount int `json:"cancellations_count"`
LastPenaltyReason string `json:"last_penalty_reason"`
MustChangePassword bool `json:"must_change_password"`
PushToken string `json:"-"`
ReferralBalance float64 `json:"referral_balance"`
CreatedAt time.Time `json:"created_at"`
}
+46 -181
View File
@@ -45,13 +45,15 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
}
// ============================================
// 📦 PRODUITS (v1) - PUBLIC (SANS middleware!)
// 📦 PRODUITS & CATÉGORIES (v1) - PUBLIC (SANS middleware!)
// ============================================
productsGroupV1 := router.Group("/api/v1")
{
productsGroupV1.GET("/products", handlers.GetAllProducts)
productsGroupV1.GET("/products/:id", handlers.GetProductByID)
productsGroupV1.GET("/products/category/:category", handlers.GetProductsByCategory)
productsGroupV1.GET("/categories", handlers.GetCategories)
productsGroupV1.GET("/app-settings", handlers.GetPublicSettings)
}
// ============================================
@@ -68,8 +70,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cartGroupV1.DELETE("/panier/clear", handlers.ClearBasket) // ✅ CORRIGÉ - Sans :username
// Commandes
cartGroupV1.POST("/checkout", middleware.OrderHoursMiddleware, handlers.ValidateBasket) // ✅ Auto-assign GPS
cartGroupV1.GET("/my-commands", handlers.GetMyCommandsWithTracking) // ✅ Avec suivi
cartGroupV1.POST("/checkout", middleware.OrderHoursMiddleware, middleware.BlockClientIfPenalty, handlers.ValidateBasket) // ✅ Auto-assign GPS
cartGroupV1.GET("/my-commands", handlers.GetMyCommandsWithTracking) // ✅ Avec suivi
// ⭐ NOUVEAUX - SUIVI CLIENT TEMPS RÉEL
cartGroupV1.GET("/commands/:id/eta", handlers.GetOrderETA) // ✅ AJOUTÉ
@@ -102,6 +104,9 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// 👤 PROFIL CLIENT - MODIFICATION PAR LE CLIENT
cartGroupV1.PUT("/profile/update", handlers.UpdateMyProfile) // ✅ Modifier mon profil
// 🎁 PARRAINAGE CLIENT
cartGroupV1.GET("/referral/balance", handlers.GetMyReferralBalance)
}
// ============================================
@@ -133,6 +138,14 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2 := router.Group("/api/v2/admin/protected")
adminGroupV2.Use(middleware.AdminMiddleware)
{
// PUSH TOKEN ADMIN
adminGroupV2.POST("/push-token", handlers.RegisterAdminPushToken)
adminGroupV2.DELETE("/push-token", handlers.UnregisterAdminPushToken)
// 🔔 NOTIFICATIONS ADMIN
adminGroupV2.GET("/notifications", handlers.GetLivreurNotifications)
adminGroupV2.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
// ============================================
// CLIENT - GESTION
// ============================================
@@ -163,6 +176,12 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.POST("/products/:id/media", handlers.UploadMedia)
adminGroupV2.DELETE("/products/:id/media/:media_id", handlers.DeleteMedia)
// ============================================
// CATÉGORIES - GESTION ADMIN
// ============================================
adminGroupV2.POST("/categories", handlers.CreateCategory)
adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory)
adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory)
// ============================================
// COMMANDES - GESTION DE BASE
// ============================================
adminGroupV2.GET("/orders", handlers.GetAllCommands)
@@ -225,6 +244,16 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
// ============================================
// ⚙️ PARAMÈTRES GLOBAUX
// ============================================
adminGroupV2.GET("/settings", handlers.GetSettings)
adminGroupV2.PUT("/settings", handlers.UpdateSettings)
// 🎁 PARRAINAGE ADMIN
adminGroupV2.GET("/client/:username/referral", handlers.GetClientReferralAdmin)
adminGroupV2.POST("/client/:username/referral/credit", handlers.CreditClientReferralAdmin)
// ============================================
// ⭐⭐ ALERTES POLICE - GESTION ADMIN
// ============================================
@@ -241,6 +270,20 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
cabineGroupV1 := router.Group("/api/v1/cabine")
cabineGroupV1.Use(middleware.CabineMiddleware)
{
// ============================================
// ADDRESSES - GESTION
// ============================================
cabineGroupV1.POST("/add/address", handlers.AddAddress)
cabineGroupV1.DELETE("/delete/address", handlers.DeleteAddress)
cabineGroupV1.GET("/addresses", handlers.GetAllAddress)
// PUSH TOKEN CABINE
cabineGroupV1.POST("/push-token", handlers.RegisterCabinePushToken)
cabineGroupV1.DELETE("/push-token", handlers.UnregisterCabinePushToken)
// 🔔 NOTIFICATIONS CABINE
cabineGroupV1.GET("/notifications", handlers.GetLivreurNotifications)
cabineGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead)
cabineGroupV1.GET("/commands/:id/items", handlers.ShowItems)
cabineGroupV1.POST("/commands/:id/confirm-reception", handlers.StaffApproveDelivery)
cabineGroupV1.POST("/commands/:id/assign", handlers.AssignDeliveryPerson)
@@ -314,181 +357,3 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
livreurGroupV1.DELETE("/push-token", handlers.UnregisterLivreurPushToken)
}
}
// ============================================
// 📝 DOCUMENTATION COMPLÈTE
// ============================================
/*
LISTE COMPLÈTE DES ROUTES:
CLIENT API (v1) - /api/v1
📌 AUTH (PUBLIC)
POST /api/v1/auth/register Créer compte client
POST /api/v1/auth/login Login client
POST /api/v1/auth/logout Logout client
📌 PRODUITS (PUBLIC)
GET /api/v1/products Tous les produits
GET /api/v1/products/:id Un produit
GET /api/v1/products/category/:cat Par catégorie
GET /api/v1/health Health check
📌 GÉOCODAGE (PUBLIC)
POST /api/v1/geocode Convertir adresse en GPS
POST /api/v1/validate-address Valider une adresse
📌 PANIER (AUTH CLIENT) 🔒
POST /api/v1/panier/add Ajouter au panier
GET /api/v1/panier/:username Voir panier
DELETE /api/v1/panier/remove Supprimer du panier
DELETE /api/v1/panier/clear Vider panier
📌 COMMANDES (AUTH CLIENT) 🔒
POST /api/v1/checkout Valider panier AUTO-ASSIGN GPS
GET /api/v1/my-commands Mes commandes avec suivi
GET /api/v1/commands/:id/eta NOUVEAU - ETA de la commande
GET /api/v1/commands/:id/status NOUVEAU - Statut temps réel
GET /api/v1/commands/:id/tracking NOUVEAU - Timeline détaillée
POST /api/v1/commands/:id/approve Approuver livraison
POST /api/v1/commands/:id/cancel NOUVEAU - Annuler commande
GET /api/v1/my-cancellation-history NOUVEAU - Historique annulations
GET /api/v1/my-commands/history NOUVEAU - Historique commandes
GET /api/v1/my-commands/history/detailed NOUVEAU - Historique détaillé
GET /api/v1/commands/:id/history NOUVEAU - Historique d'une commande
📌 PÉNALITÉS (AUTH CLIENT) 🔒
GET /api/v1/penalties NOUVEAU - Voir mes pénalités
📌 PROFIL (AUTH CLIENT) 🔒
PUT /api/v1/profile/update NOUVEAU - Modifier mon profil
ADMIN API (v2) - /api/v2/admin
📌 AUTH (PUBLIC)
POST /api/v2/admin/auth/register Créer compte admin
POST /api/v2/admin/auth/login Login admin
POST /api/v2/admin/auth/logout Logout admin
📌 GESTION UTILISATEURS (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/all/clients Liste tous les clients
GET /api/v2/admin/protected/all/users Liste tous les users
PUT /api/v2/admin/protected/clients/:id NOUVEAU - Modifier un client
PUT /api/v2/admin/protected/users/:id NOUVEAU - Modifier un user
📌 PRODUITS (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/products Tous produits
POST /api/v2/admin/protected/products Créer produit
GET /api/v2/admin/protected/products/:id Détail produit
PUT /api/v2/admin/protected/products/:id Modifier produit
DELETE /api/v2/admin/protected/products/:id Supprimer produit
📌 COMMANDES (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/orders Toutes commandes
GET /api/v2/admin/protected/orders/:id Détail commande
PUT /api/v2/admin/protected/orders/:id/address Modifier adresse
POST /api/v2/admin/protected/orders/:id/force-validate Forcer validation
GET /api/v2/admin/protected/orders/cancelled Commandes annulées
GET /api/v2/admin/protected/commands/:id/deliveryman/location Position livreur pour commande
📌 AUTO-ASSIGNATION GPS (AUTH ADMIN) 🔒
POST /api/v2/admin/protected/orders/:id/auto-assign Assigner 1 commande
POST /api/v2/admin/protected/commands/auto-assign-all Assigner toutes
📌 RECHERCHE LIVREUR (AUTH ADMIN) 🔒
POST /api/v2/admin/protected/delivery/nearest Livreur le plus proche
POST /api/v2/admin/protected/delivery/distances Tous livreurs + distances
📌 GESTION QUEUES (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/delivery/queues Toutes les queues
GET /api/v2/admin/protected/delivery/:username/queue Queue d'un livreur
📌 VISUALISATION (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/delivery/heatmap Heatmap livreurs
📌 GÉOCODAGE (AUTH ADMIN) 🔒
POST /api/v2/admin/protected/geocode Convertir adresse
POST /api/v2/admin/protected/validate-address Valider adresse
📌 GESTION LIVREURS (AUTH ADMIN) 🔒
GET /api/v2/admin/protected/delivery-persons Liste livreurs
GET /api/v2/admin/protected/delivery-persons/:username Détails d'un livreur
GET /api/v2/admin/protected/delivery-persons/:username/stats Statistiques livreur
GET /api/v2/admin/protected/delivery-persons/:username/history Historique livreur
GET /api/v2/admin/protected/delivery-persons/:username/location NOUVEAU - Position GPS livreur
PUT /api/v2/admin/protected/delivery-persons/:username/location Modifier position livreur
PUT /api/v2/admin/protected/delivery-persons/:username/status Modifier statut livreur
POST /api/v2/admin/protected/delivery-persons/:username/assign/:command_id Assigner manuellement
DELETE /api/v2/admin/protected/delivery-persons/:username/queue/:command_id Retirer de la queue
GET /api/v2/admin/protected/delivery-persons/:username/map-links Liens carte
📌 PÉNALITÉS (AUTH ADMIN) 🔒
POST /api/v2/admin/protected/penalty Appliquer pénalité
GET /api/v2/admin/protected/client/:username/penalties Voir pénalités client
POST /api/v2/admin/protected/client/:username/penalties/reset Reset pénalités
GET /api/v2/admin/protected/penalties/all Tous clients avec pénalités
GET /api/v2/admin/protected/penalties/stats Stats pénalités
CABINE API (v1) - /api/v1/cabine
📌 GESTION ITEMS (AUTH CABINE) 🔒
GET /api/v1/cabine/commands/:id/items Voir items d'une commande
PUT /api/v1/cabine/items/:item_id/status Changer statut item
GET /api/v1/cabine/commands/cancelled Commandes annulées
GET /api/v1/cabine/commands/:id/deliveryman/location Position livreur pour commande
📌 PÉNALITÉS (AUTH CABINE) 🔒
POST /api/v1/cabine/penalty Appliquer pénalité
GET /api/v1/cabine/client/:username/penalties Voir pénalités client
POST /api/v1/cabine/client/:username/penalties/reset Reset pénalités
GET /api/v1/cabine/penalties/all Tous clients avec pénalités
GET /api/v1/cabine/penalties/stats Stats pénalités
LIVREUR API (v1) - /api/v1/livreur
📌 LIVRAISONS (AUTH LIVREUR) 🔒
GET /api/v1/livreur/deliveries Mes livraisons (DONNÉES FILTRÉES)
GET /api/v1/livreur/deliveries/:id NOUVEAU - Détail livraison
POST /api/v1/livreur/deliveries/:id/start Démarrer livraison
PUT /api/v1/livreur/deliveries/:id/status Changer statut (AVEC GPS)
📌 POSITION GPS (AUTH LIVREUR) 🔒
POST /api/v1/livreur/location/update Mettre à jour ma position
GET /api/v1/livreur/location Voir ma position actuelle
📌 STATUT (AUTH LIVREUR) 🔒
POST /api/v1/livreur/status Changer mon statut
GET /api/v1/livreur/status Voir mon statut
📌 QUEUE (AUTH LIVREUR) 🔒
GET /api/v1/livreur/queue Voir ma queue de livraisons
*/
// ============================================
// 🔧 CORRECTIONS APPLIQUÉES
// ============================================
/*
1. Route ETA ajoutée: GET /api/v1/commands/:id/eta
2. Route tracking détaillée: GET /api/v1/commands/:id/tracking
3. Route status temps réel: GET /api/v1/commands/:id/status
4. Panier clear sans :username (utilise JWT)
5. GeoService injecté dans le contexte global
6. Routes livreur pour GPS et statut
7. Routes admin pour gestion manuelle livreurs
8. Routes modification profil CLIENT par le client: PUT /api/v1/profile/update
9. Routes modification profil CLIENT par admin: PUT /api/v2/admin/protected/clients/:id
10. Routes modification profil USER par admin: PUT /api/v2/admin/protected/users/:id
11. Route position GPS livreur par admin: GET /api/v2/admin/protected/delivery-persons/:username/location
*/
+10 -5
View File
@@ -184,16 +184,21 @@ func tryAssignCommandWithPriority(
// 8. Notifier le livreur de la nouvelle commande
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
var clientUsername string
if cmd, err := database.GetCommandByID(commandID); err == nil {
if ru, ok := cmd["referral_used"].(float64); ok && ru > 0 {
notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", ru)
}
clientUsername, _ = cmd["username"].(string)
}
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CRON] Erreur notification livreur %s: %v", nearest.Username, notifErr)
}
// 9. Notifier le client
if cmd, err := database.GetCommandByID(commandID); err == nil {
if clientUsername, ok := cmd["username"].(string); ok && clientUsername != "" {
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime)
database.NotifyClient(clientUsername, commandID, "assigned", clientMsg)
}
if clientUsername != "" {
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Un livreur est en route.", commandID)
database.NotifyClient(clientUsername, commandID, "assigned", clientMsg)
}
log.Printf("✅ [CRON] Cmd %d → %s (%.2f km) | Priorité #%d | Attente: %d min",
-149
View File
@@ -5,30 +5,20 @@
package workers
import (
"encoding/json"
"gestion/db"
"log"
"time"
)
// ============================================
// WORKER PRINCIPAL
// ============================================
// StartRedisWorkers démarre tous les workers Redis
func StartRedisWorkers(database *db.Database) {
log.Println("🚀 Démarrage des workers Redis...")
// Worker pour les notifications programmées
go NotificationWorker(database)
// Worker pour l'auto-assignation des commandes
go AutoAssignWorker(database)
// Worker pour le nettoyage des réservations expirées
go StockCleanupWorker(database)
// Worker pour la synchronisation des points
go PointsSyncWorker(database)
log.Println("✅ Tous les workers Redis sont démarrés")
@@ -174,142 +164,3 @@ func PointsSyncWorker(database *db.Database) {
}
}
}
// ============================================
// WORKER MISE À JOUR ETA
// ============================================
// ETAUpdateWorker met à jour automatiquement les ETAs
func ETAUpdateWorker(database *db.Database) {
ticker := time.NewTicker(2 * time.Minute)
defer ticker.Stop()
log.Println("⏱️ Worker Mise à Jour ETA démarré (check toutes les 2 min)")
for range ticker.C {
// Récupérer toutes les commandes en cours de livraison
commands, err := database.GetAllCommands("support", "")
if err != nil {
continue
}
for _, command := range commands {
commandID := command["id"].(int)
// Recalculer l'ETA basé sur la position du livreur
// (logique à implémenter selon vos besoins)
// Exemple: réduire l'ETA de 2 minutes
// database.SetCommandETA(commandID, newETA)
log.Printf("🔄 ETA mis à jour pour commande %d", commandID)
}
}
}
// ============================================
// WORKER ALERTES RETARD
// ============================================
// DelayAlertWorker envoie des alertes en cas de retard
func DelayAlertWorker(database *db.Database) {
ticker := time.NewTicker(3 * time.Minute)
defer ticker.Stop()
log.Println("⚠️ Worker Alertes Retard démarré (check toutes les 3 min)")
for range ticker.C {
// Récupérer les ETAs de toutes les commandes
keys, err := db.Redis.Keys(db.RedisCtx, "command:eta:*").Result()
if err != nil {
continue
}
for _, key := range keys {
data, err := db.Redis.Get(db.RedisCtx, key).Result()
if err != nil {
continue
}
// Désérialiser le JSON dans eta
var eta map[string]interface{}
if err := json.Unmarshal([]byte(data), &eta); err != nil {
log.Printf("⚠️ Impossible de parser ETA pour %s: %v", key, err)
continue
}
// Extraire l'heure d'arrivée
arrivalTimeFloat, ok := eta["arrival_time"].(float64)
if !ok {
continue
}
arrivalTime := int64(arrivalTimeFloat)
// Vérifier retard
now := time.Now().Unix()
if now > arrivalTime {
commandIDFloat, ok := eta["command_id"].(float64)
if !ok {
continue
}
commandID := int(commandIDFloat)
delay := (now - arrivalTime) / 60 // en minutes
log.Printf("⚠️ ALERTE: Commande %d en retard de %d minutes", commandID, delay)
// Notifier l'admin
database.PublishCommandEvent(commandID, "delay_alert", "Commande en retard")
if delay > 15 {
command, _ := database.GetCommandByID(commandID)
if livreur, ok := command["livreur_assign"].(string); ok {
log.Printf("⚠️ Pénalité appliquée au livreur %s", livreur)
}
}
}
}
}
}
// ============================================
// WORKER STATISTIQUES TEMPS RÉEL
// ============================================
// StatsWorker calcule des statistiques en temps réel
func StatsWorker(database *db.Database) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
log.Println("📊 Worker Statistiques démarré (check toutes les 5 min)")
for range ticker.C {
// Nombre de commandes en attente
queueSize, _ := db.Redis.ZCard(db.RedisCtx, "queue:pending:sorted").Result()
// Nombre de livreurs disponibles
livreurs, _ := database.GetAvailableDeliveryPersonsRedis()
availableCount := len(livreurs)
// Nombre de livraisons en cours
commands, _ := database.GetAllCommands("support", "")
inProgressCount := len(commands)
stats := map[string]interface{}{
"queue_size": queueSize,
"available_drivers": availableCount,
"in_progress": inProgressCount,
"timestamp": time.Now().Unix(),
}
// Stocker dans Redis
db.Redis.HSet(db.RedisCtx, "stats:realtime",
"queue_size", stats["queue_size"],
"available_drivers", stats["available_drivers"],
"in_progress", stats["in_progress"],
"timestamp", stats["timestamp"],
)
log.Printf("📊 Stats: %d en attente | %d livreurs dispo | %d en cours",
queueSize, availableCount, inProgressCount)
}
}