From 22a8d5026c518ceb4dc9f738810c479912ea1111 Mon Sep 17 00:00:00 2001 From: Xor290 Date: Thu, 6 Aug 2026 12:06:05 +0200 Subject: [PATCH] chore: build --- backend/gestion/db/db_address.go | 28 +- backend/gestion/db/db_alert.go | 6 +- backend/gestion/db/db_basket.go | 84 +- backend/gestion/db/db_cancel_command.go | 123 +- backend/gestion/db/db_categories.go | 19 +- backend/gestion/db/db_clients.go | 123 +- backend/gestion/db/db_command_items.go | 219 +- backend/gestion/db/db_commands.go | 302 +- backend/gestion/db/db_delivery.go | 1 - backend/gestion/db/db_gps_link.go | 4 - backend/gestion/db/db_init.go | 64 +- backend/gestion/db/db_livreur_rating.go | 62 + backend/gestion/db/db_login_history.go | 34 + backend/gestion/db/db_media.go | 37 +- backend/gestion/db/db_notifications.go | 11 +- backend/gestion/db/db_parrain.go | 38 +- backend/gestion/db/db_product.go | 93 +- backend/gestion/db/db_product_price.go | 14 + backend/gestion/db/db_queue_auto_next.go | 2 +- backend/gestion/db/db_sanction_client.go | 93 +- backend/gestion/db/db_settings.go | 48 + backend/gestion/db/db_stat.go | 449 + backend/gestion/db/db_telegram.go | 2 +- backend/gestion/db/db_users.go | 4 +- .../db/redis_deliveryman_assignment.go | 2 +- backend/gestion/db/redis_eta_management.go | 1 + backend/gestion/db/redis_queue_clean_up.go | 2 - backend/gestion/db/redis_queue_info.go | 1 - backend/gestion/db/redis_queue_management.go | 2 - backend/gestion/go.mod | 18 + backend/gestion/go.sum | 36 + backend/gestion/handlers/alert.go | 29 +- backend/gestion/handlers/auth.go | 178 +- backend/gestion/handlers/cancel_command.go | 2 - backend/gestion/handlers/categories.go | 20 + backend/gestion/handlers/client_tracking.go | 2 +- backend/gestion/handlers/commands.go | 20 +- backend/gestion/handlers/crypto_payment.go | 7 +- backend/gestion/handlers/deleviry.go | 150 +- backend/gestion/handlers/delivery_admin.go | 5 +- backend/gestion/handlers/eta.go | 16 +- backend/gestion/handlers/geoloca.go | 50 +- backend/gestion/handlers/gps.go | 6 - backend/gestion/handlers/history.go | 94 +- .../gestion/handlers/livreur_login_history.go | 80 + backend/gestion/handlers/notifications.go | 5 +- backend/gestion/handlers/panier.go | 90 +- backend/gestion/handlers/parrain.go | 24 +- backend/gestion/handlers/points.go | 189 +- backend/gestion/handlers/product.go | 231 +- backend/gestion/handlers/rating.go | 140 + backend/gestion/handlers/redis_services.go | 66 +- backend/gestion/handlers/settings.go | 7 + backend/gestion/handlers/stats.go | 441 +- backend/gestion/handlers/update_profile.go | 3 - backend/gestion/main.go | 27 +- backend/gestion/models/media.go | 11 +- backend/gestion/models/settings.go | 15 + backend/gestion/models/stats.go | 32 + backend/gestion/routes/routes.go | 29 +- .../gestion/services/adresses_correction.go | 57 +- backend/gestion/services/geo_services.go | 23 +- backend/gestion/services/lbtelegram.go | 4 +- backend/gestion/services/s3.go | 135 + backend/gestion/services/storage.go | 105 + backend/gestion/services/telegram.go | 8 +- backend/gestion/services/tomtom.go | 66 - backend/gestion/services/tomtom_keys.go | 7 +- .../address_correction_integration_test.go | 126 + .../gestion/tests/address_correction_test.go | 243 + backend/gestion/tests/alert_test.go | 273 + .../gestion/tests/commande_coverage_test.go | 256 + .../tests/commands_address_proposal_test.go | 210 + .../gestion/tests/commands_address_test.go | 227 + .../tests/commands_admin_cancel_test.go | 124 + .../tests/commands_handlers_coverage_test.go | 222 + backend/gestion/tests/deleviry_gps_test.go | 188 + backend/gestion/tests/delivery_mode_test.go | 261 + backend/gestion/tests/eta_coverage_test.go | 194 + backend/gestion/tests/eta_geo_calc_test.go | 79 + backend/gestion/tests/eta_last_coords_test.go | 59 + .../gestion/tests/eta_notifications_test.go | 179 + backend/gestion/tests/eta_test.go | 208 + backend/gestion/tests/livreur_stats_test.go | 137 + backend/gestion/tests/main_test.go | 140 + .../gestion/tests/media_s3_coverage_test.go | 276 + backend/gestion/tests/parrain_test.go | 160 + backend/gestion/tests/penalty_test.go | 405 + .../tests/points_approval_staff_test.go | 256 + backend/gestion/tests/points_approval_test.go | 404 + backend/gestion/tests/referral_test.go | 254 + backend/gestion/tests/rewards_handler_test.go | 132 + backend/gestion/tests/rewards_test.go | 411 + backend/gestion/tests/stats_advanced_test.go | 162 + backend/gestion/tests/stats_coverage_test.go | 59 + backend/gestion/tests/stats_reset_test.go | 104 + backend/gestion/tests/stats_test.go | 208 + backend/gestion/tests/stock_cancel_test.go | 284 + backend/gestion/tests/stock_checkout_test.go | 200 + backend/gestion/tests/stock_coverage_test.go | 428 + backend/gestion/tests/stock_item_test.go | 116 + .../tests/stock_reward_same_product_test.go | 137 + .../tests/utils_normalize_address_test.go | 57 + backend/gestion/utils/auth.go | 22 +- backend/gestion/utils/utils.go | 45 +- backend/gestion/workers/payment_checker.go | 10 - frontend-admin/app.json | 11 +- frontend-admin/certs/certificate.pem | 18 + frontend-admin/eas.json | 4 +- frontend-admin/package-lock.json | 34 +- frontend-admin/src/api/api_admin.ts | 215 +- frontend-admin/src/api/api_delivery.ts | 29 + frontend-admin/src/api/tomtom.ts | 1 + frontend-admin/src/api/types.ts | 238 +- frontend-admin/src/auth/tokenStorage.ts | 43 +- frontend-admin/src/context/ThemeContext.tsx | 46 +- .../src/navigation/AdminNavigator.tsx | 8 +- .../src/navigation/DeliveryNavigator.tsx | 15 + frontend-admin/src/navigation/types.ts | 1 + .../src/screens/admin/CategoriesScreen.tsx | 41 +- .../src/screens/admin/DeliveryScreen.tsx | 479 + .../src/screens/admin/OrdersScreen.tsx | 2 +- .../src/screens/admin/ProductsScreen.tsx | 97 +- .../src/screens/admin/SettingsScreen.tsx | 198 +- .../src/screens/admin/StatsScreen.tsx | 2349 +- .../src/screens/admin/UsersScreen.tsx | 1255 +- .../src/screens/delivery/RatingsScreen.tsx | 152 + .../src/screens/delivery/StatsScreen.tsx | 3 + frontend-admin/src/theme/colors.ts | 2 + frontend-prep/package-lock.json | 15 - frontend-prep/src/App.css | 10 +- frontend-prep/src/api/api.ts | 109 +- frontend-prep/src/api/api_types.ts | 8 + frontend-prep/src/components/Items.css | 680 +- frontend-prep/src/components/Navbar.css | 483 +- frontend-prep/src/components/Navbar.tsx | 206 +- frontend-prep/src/components/ProductCard.tsx | 59 +- frontend-prep/src/components/Toast.css | 107 +- frontend-prep/src/context/ThemeContext.tsx | 88 +- frontend-prep/src/index.css | 168 +- frontend-prep/src/pages/Home.css | 309 +- frontend-prep/src/pages/User/Accueil.tsx | 45 +- frontend-prep/src/pages/User/Cart.css | 69 +- frontend-prep/src/pages/User/Checkout.tsx | 50 + .../src/pages/User/ConsultationHistorique.css | 270 +- .../src/pages/User/ConsultationHistorique.tsx | 464 +- frontend-prep/src/pages/User/OrderDetails.css | 142 + frontend-prep/src/pages/User/OrderDetails.tsx | 81 + frontend-prep/src/pages/User/Parrainage.css | 21 +- frontend-prep/src/pages/User/ProfilePage.tsx | 1267 +- .../src/pages/User/SuiviLivraison.tsx | 224 +- frontend-prep/src/pages/User/UserAccueil.css | 32 +- mobile/.gitignore | 1 + mobile/App.tsx | 6 + mobile/app.json | 117 +- mobile/certs/certificate.pem | 18 + mobile/eas.json | 8 +- mobile/package-lock.json | 22183 ++++++++-------- mobile/package.json | 108 +- mobile/src/api/api.ts | 132 +- mobile/src/api/api_types.ts | 10 +- mobile/src/auth/AuthContext.tsx | 313 +- mobile/src/context/ThemeContext.tsx | 41 +- mobile/src/navigation/types.ts | 1 + mobile/src/screens/HomeScreen.tsx | 3 +- mobile/src/screens/auth/ClientLoginScreen.tsx | 6 +- mobile/src/screens/auth/ClientTwoFAScreen.tsx | 200 + .../src/screens/client/OrderDetailsScreen.tsx | 179 +- .../src/screens/client/OrderHistoryScreen.tsx | 12 +- .../screens/client/OrderTrackingScreen.tsx | 83 +- .../screens/client/ProductDetailScreen.tsx | 41 + mobile/src/screens/client/ProductsScreen.tsx | 53 +- mobile/src/screens/client/ProfileScreen.tsx | 1218 +- mobile/src/theme/colors.ts | 2 + 174 files changed, 30315 insertions(+), 16120 deletions(-) create mode 100644 backend/gestion/db/db_livreur_rating.go create mode 100644 backend/gestion/db/db_login_history.go create mode 100644 backend/gestion/db/db_stat.go create mode 100644 backend/gestion/handlers/livreur_login_history.go create mode 100644 backend/gestion/handlers/rating.go create mode 100644 backend/gestion/services/s3.go create mode 100644 backend/gestion/services/storage.go create mode 100644 backend/gestion/tests/address_correction_integration_test.go create mode 100644 backend/gestion/tests/address_correction_test.go create mode 100644 backend/gestion/tests/alert_test.go create mode 100644 backend/gestion/tests/commande_coverage_test.go create mode 100644 backend/gestion/tests/commands_address_proposal_test.go create mode 100644 backend/gestion/tests/commands_address_test.go create mode 100644 backend/gestion/tests/commands_admin_cancel_test.go create mode 100644 backend/gestion/tests/commands_handlers_coverage_test.go create mode 100644 backend/gestion/tests/deleviry_gps_test.go create mode 100644 backend/gestion/tests/delivery_mode_test.go create mode 100644 backend/gestion/tests/eta_coverage_test.go create mode 100644 backend/gestion/tests/eta_geo_calc_test.go create mode 100644 backend/gestion/tests/eta_last_coords_test.go create mode 100644 backend/gestion/tests/eta_notifications_test.go create mode 100644 backend/gestion/tests/eta_test.go create mode 100644 backend/gestion/tests/livreur_stats_test.go create mode 100644 backend/gestion/tests/main_test.go create mode 100644 backend/gestion/tests/media_s3_coverage_test.go create mode 100644 backend/gestion/tests/parrain_test.go create mode 100644 backend/gestion/tests/penalty_test.go create mode 100644 backend/gestion/tests/points_approval_staff_test.go create mode 100644 backend/gestion/tests/points_approval_test.go create mode 100644 backend/gestion/tests/referral_test.go create mode 100644 backend/gestion/tests/rewards_handler_test.go create mode 100644 backend/gestion/tests/rewards_test.go create mode 100644 backend/gestion/tests/stats_advanced_test.go create mode 100644 backend/gestion/tests/stats_coverage_test.go create mode 100644 backend/gestion/tests/stats_reset_test.go create mode 100644 backend/gestion/tests/stats_test.go create mode 100644 backend/gestion/tests/stock_cancel_test.go create mode 100644 backend/gestion/tests/stock_checkout_test.go create mode 100644 backend/gestion/tests/stock_coverage_test.go create mode 100644 backend/gestion/tests/stock_item_test.go create mode 100644 backend/gestion/tests/stock_reward_same_product_test.go create mode 100644 backend/gestion/tests/utils_normalize_address_test.go create mode 100644 frontend-admin/certs/certificate.pem create mode 100644 frontend-admin/src/screens/delivery/RatingsScreen.tsx create mode 100644 mobile/certs/certificate.pem create mode 100644 mobile/src/screens/auth/ClientTwoFAScreen.tsx diff --git a/backend/gestion/db/db_address.go b/backend/gestion/db/db_address.go index b600d9da..26324967 100644 --- a/backend/gestion/db/db_address.go +++ b/backend/gestion/db/db_address.go @@ -3,19 +3,35 @@ package db import ( "fmt" "gestion/models" + "gestion/utils" + "strings" ) func (d *Database) CheckAddress(addressByUser *models.Command) error { var correction models.Address result := d.GDB.Where("invalid_address = ?", addressByUser.DeliveryAddress).First(&correction) - if result.Error != nil { - if isNotFound(result.Error) { - return nil - } + if result.Error == nil { + addressByUser.DeliveryAddress = correction.CorrectAddress + return fmt.Errorf("adresse invalide %s", correction.CorrectAddress) + } + if !isNotFound(result.Error) { return fmt.Errorf("checkAddress: %w", result.Error) } - addressByUser.DeliveryAddress = correction.CorrectAddress - return fmt.Errorf("Adresse invalide %s", correction.CorrectAddress) + + // Pas de correspondance exacte — fallback sur une comparaison normalisée + // (accents/casse/espaces) pour rattraper les variantes mineures de saisie. + corrections, err := d.AllAddress() + if err != nil { + return nil + } + normalizedInput := utils.NormalizeAddress(addressByUser.DeliveryAddress) + for _, c := range corrections { + if strings.EqualFold(utils.NormalizeAddress(c.InvalidAddress), normalizedInput) { + addressByUser.DeliveryAddress = c.CorrectAddress + return fmt.Errorf("adresse invalide %s", c.CorrectAddress) + } + } + return nil } func (d *Database) AddAddress(CorrectAddressByAdmin string, InvalidAddressByAdmin string) error { diff --git a/backend/gestion/db/db_alert.go b/backend/gestion/db/db_alert.go index 63950898..cee7aec9 100644 --- a/backend/gestion/db/db_alert.go +++ b/backend/gestion/db/db_alert.go @@ -27,7 +27,7 @@ func (d *Database) GetAlertPolicy(id int) (models.AlertPolicy, error) { func (d *Database) GetAllAlerts() ([]models.AlertPolicy, error) { var alerts []models.AlertPolicy - if err := d.GDB.Find(&alerts).Error; err != nil { + if err := d.GDB.Order("created_at DESC").Limit(500).Find(&alerts).Error; err != nil { return nil, err } return alerts, nil @@ -65,7 +65,7 @@ func (d *Database) ActivateAlert(id int) error { func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) { var alerts []models.AlertPolicy - if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Find(&alerts).Error; err != nil { + if err := d.GDB.Where("status = 'true'").Order("created_at DESC").Limit(100).Find(&alerts).Error; err != nil { return nil, err } return alerts, nil @@ -73,7 +73,7 @@ func (d *Database) GetActiveAlerts() ([]models.AlertPolicy, error) { func (d *Database) GetAlertsByUsername(username string) ([]models.AlertPolicy, error) { var alerts []models.AlertPolicy - if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Find(&alerts).Error; err != nil { + if err := d.GDB.Where("username = ?", username).Order("created_at DESC").Limit(200).Find(&alerts).Error; err != nil { return nil, err } return alerts, nil diff --git a/backend/gestion/db/db_basket.go b/backend/gestion/db/db_basket.go index a68d2623..30b80975 100644 --- a/backend/gestion/db/db_basket.go +++ b/backend/gestion/db/db_basket.go @@ -7,7 +7,6 @@ import ( "gorm.io/gorm" ) -// GetProductPrice récupère le prix réel d'un produit pour une quantité donnée (legacy) func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) { var result struct { Price float64 `gorm:"column:price"` @@ -49,27 +48,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) { var baskets []models.Panier err := d.GDB.Transaction(func(tx *gorm.DB) error { - // Supprimer tout article récompense existant (remplacement) - tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username) - for _, item := range items { - if item.ProductID <= 0 || item.Quantity <= 0 { - continue - } - var productName string - if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" { - return fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID) - } - var basket models.Panier - if err := tx.Raw(` - INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at) - VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP) - RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`, - username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil { - return err - } - baskets = append(baskets, basket) - } - return nil + var err error + baskets, err = addRewardsToBasketTx(tx, username, items, poolKey) + return err }) if err != nil { return nil, err @@ -77,6 +58,36 @@ func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem return baskets, nil } +// addRewardsToBasketTx contient la logique de remplacement des articles +// récompense, factorisée pour être appelée soit seule (AddRewardsToBasket), +// soit dans la même transaction qu'une autre opération (voir +// ClaimPoolRewardAndAddToBasket) afin de garantir qu'une récompense n'est +// jamais consommée sans que son produit soit effectivement livré. +func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) { + // Supprimer tout article récompense existant (remplacement) + tx.Exec(`DELETE FROM baskets WHERE username = ? AND is_reward = true`, username) + var baskets []models.Panier + for _, item := range items { + if item.ProductID <= 0 || item.Quantity <= 0 { + continue + } + var productName string + if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, item.ProductID).Scan(&productName).Error; err != nil || productName == "" { + return nil, fmt.Errorf("produit récompense introuvable (id=%d)", item.ProductID) + } + var basket models.Panier + if err := tx.Raw(` + INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at) + VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP) + RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`, + username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil { + return nil, err + } + baskets = append(baskets, basket) + } + return baskets, nil +} + // HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense. func (d *Database) HasOnlyRewardItems(username string) (bool, error) { var counts struct { @@ -164,35 +175,6 @@ func (d *Database) ClearBasket(username string) error { return d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error } -// ClearBasketOnCheckout décrémente le stock pour chaque article du panier puis vide le panier. -// C'est ici que le stock est effectivement consommé, au moment de la validation de la commande. -func (d *Database) ClearBasketOnCheckout(username string) error { - return d.GDB.Transaction(func(tx *gorm.DB) error { - var items []struct { - ProductID int `gorm:"column:product_id"` - Quantity float64 `gorm:"column:quantity"` - } - if err := tx.Raw(`SELECT product_id, quantity FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&items).Error; err != nil { - return fmt.Errorf("erreur lecture panier: %w", err) - } - - for _, item := range items { - var currentStock float64 - if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil { - return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err) - } - if currentStock < item.Quantity { - return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID) - } - if err := tx.Exec(`UPDATE products SET stock = stock - ? WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil { - return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err) - } - } - - return tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error - }) -} - func (d *Database) GetBasketItemOwner(basketID int) (string, error) { var username string err := d.GDB.Raw(`SELECT username FROM baskets WHERE id = ?`, basketID).Scan(&username).Error diff --git a/backend/gestion/db/db_cancel_command.go b/backend/gestion/db/db_cancel_command.go index 9b608313..51f41487 100644 --- a/backend/gestion/db/db_cancel_command.go +++ b/backend/gestion/db/db_cancel_command.go @@ -78,9 +78,14 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f if err := tx.Exec(` UPDATE products p - SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP - FROM command_items ci - WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil { + SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP + FROM ( + SELECT product_id, SUM(quantite) AS total_qty + FROM command_items + WHERE command_id = ? + GROUP BY product_id + ) agg + WHERE agg.product_id = p.id`, commandID).Error; err != nil { return fmt.Errorf("erreur remboursement stock: %w", err) } log.Printf("✅ [CancelAtomic] Stock remboursé") @@ -145,20 +150,18 @@ func (d *Database) CancelCommandAtomic(commandID int, username, reason string, f return penalty, nil } -// CheckCommandETAExistsAndValid vérifie si une ETA RÉELLE existe (> 0 minutes, non expirée) func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool { etaKey := fmt.Sprintf("command:eta:%d", commandID) - etaMinutesStr, err := Redis.Get(RedisCtx, etaKey).Result() - if err != nil { + etaData, err := Redis.HGetAll(RedisCtx, etaKey).Result() + if err != nil || len(etaData) == 0 { log.Printf("⚠️ [CheckETA] Pas d'ETA trouvée pour cmd %d", commandID) return false } var etaMinutes int - _, err = fmt.Sscanf(etaMinutesStr, "%d", &etaMinutes) - if err != nil || etaMinutes <= 0 { - log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaMinutesStr) + if _, err := fmt.Sscanf(etaData["eta_minutes"], "%d", &etaMinutes); err != nil || etaMinutes <= 0 { + log.Printf("⚠️ [CheckETA] ETA invalide pour cmd %d: %s", commandID, etaData["eta_minutes"]) return false } @@ -192,13 +195,18 @@ func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) er log.Printf("📋 [DeleteAtomic] Trouvée - status=%s, client=%s", cmdResult.Status, cmdResult.Username) // ✅ Ne restitue le stock QUE si pas déjà fait - stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" + stockAlreadyRestored := cmdResult.Status == "cancelled" || cmdResult.Status == "approved" || cmdResult.Status == "livre" if !stockAlreadyRestored { if err := tx.Exec(` UPDATE products p - SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP - FROM command_items ci - WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error; err != nil { + SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP + FROM ( + SELECT product_id, SUM(quantite) AS total_qty + FROM command_items + WHERE command_id = ? + GROUP BY product_id + ) agg + WHERE agg.product_id = p.id`, commandID).Error; err != nil { log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err) } else { log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status) @@ -316,12 +324,89 @@ func (d *Database) AddClientPenalty(username string, points int) error { return nil } -func (d *Database) RestoreCommandStock(commandID int) error { +// CancelCommandByAdminAtomic transitionne une commande vers 'cancelled' depuis le +// panel admin/cabine de façon atomique (verrou FOR UPDATE sur la commande) : le +// remboursement de stock et le changement de statut se font dans la même +// transaction, conditionnés à une lecture du statut précédent faite sous verrou. +// Corrige un double remboursement possible sur double-tap/appel concurrent — +// l'ancien code (RestoreCommandStock + UpdateCommandStatus appelés séparément +// par le handler) lisait le statut puis restaurait le stock hors transaction, +// laissant une fenêtre où deux requêtes concurrentes lisaient toutes les deux +// "pas encore annulée" et remboursaient chacune le stock. +func (d *Database) CancelCommandByAdminAtomic(commandID int) error { return d.GDB.Transaction(func(tx *gorm.DB) error { - return tx.Exec(` - UPDATE products p - SET stock = stock + ci.quantite, updated_at = CURRENT_TIMESTAMP - FROM command_items ci - WHERE ci.command_id = ? AND ci.product_id = p.id`, commandID).Error + var prevStatus string + if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; err != nil { + return err + } + if prevStatus == "" { + return fmt.Errorf("commande non trouvée") + } + + noRestoreStatuses := []string{"cancelled", "approved", "livre"} + if !slices.Contains(noRestoreStatuses, prevStatus) { + if err := tx.Exec(` + UPDATE products p + SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP + FROM ( + SELECT product_id, SUM(quantite) AS total_qty + FROM command_items + WHERE command_id = ? + GROUP BY product_id + ) agg + WHERE agg.product_id = p.id`, commandID).Error; err != nil { + return fmt.Errorf("erreur remboursement stock: %w", err) + } + } + + if err := tx.Exec(`UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP WHERE id = ?`, commandID).Error; err != nil { + return fmt.Errorf("erreur mise à jour statut: %w", err) + } + return nil }) } + +// CancelDeliveryByLivreurAtomic annule une commande côté livreur et restaure le stock +// de manière atomique (verrou FOR UPDATE + transition conditionnée à l'ancien statut). +// Idempotent : si la commande est déjà annulée, ne touche pas au stock et renvoie +// alreadyCancelled=true — évite un remboursement en double en cas de double appel +// (double-tap, retry réseau, ou commande déjà annulée par un autre canal). +func (d *Database) CancelDeliveryByLivreurAtomic(commandID int) (alreadyCancelled bool, prevStatus string, err error) { + err = d.GDB.Transaction(func(tx *gorm.DB) error { + if e := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&prevStatus).Error; e != nil { + return e + } + if prevStatus == "" { + return fmt.Errorf("commande non trouvée") + } + if prevStatus == "cancelled" { + alreadyCancelled = true + return nil + } + + result := tx.Exec(` + UPDATE commandes SET status = 'cancelled', updated_at = CURRENT_TIMESTAMP + WHERE id = ? AND status = ?`, commandID, prevStatus) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("commande déjà modifiée par une autre requête") + } + + if e := tx.Exec(` + UPDATE products p + SET stock = stock + agg.total_qty, updated_at = CURRENT_TIMESTAMP + FROM ( + SELECT product_id, SUM(quantite) AS total_qty + FROM command_items + WHERE command_id = ? + GROUP BY product_id + ) agg + WHERE agg.product_id = p.id`, commandID).Error; e != nil { + return fmt.Errorf("erreur remboursement stock: %w", e) + } + return nil + }) + return +} diff --git a/backend/gestion/db/db_categories.go b/backend/gestion/db/db_categories.go index 427b989f..45057b4a 100644 --- a/backend/gestion/db/db_categories.go +++ b/backend/gestion/db/db_categories.go @@ -13,6 +13,7 @@ type Category struct { Name string `json:"name" gorm:"column:name"` Color string `json:"color" gorm:"column:color"` IsComingSoon bool `json:"is_coming_soon" gorm:"column:is_coming_soon"` + Position int `json:"position" gorm:"column:position"` CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` } @@ -30,7 +31,7 @@ func ValidateCategoryColor(color string) error { func (d *Database) GetAllCategories() ([]Category, error) { var categories []Category - if err := d.GDB.Order("name ASC").Find(&categories).Error; err != nil { + if err := d.GDB.Order("position ASC, name ASC").Find(&categories).Error; err != nil { return nil, err } if categories == nil { @@ -43,7 +44,9 @@ func (d *Database) CreateCategory(name, color string, isComingSoon bool) (*Categ if color == "" { color = "#7c3aed" } - c := Category{Name: name, Color: color, IsComingSoon: isComingSoon} + var maxPos int + d.GDB.Model(&Category{}).Select("COALESCE(MAX(position), 0)").Scan(&maxPos) + c := Category{Name: name, Color: color, IsComingSoon: isComingSoon, Position: maxPos + 1} if err := d.GDB.Create(&c).Error; err != nil { return nil, err } @@ -82,6 +85,18 @@ func (d *Database) DeleteCategory(id int) error { return nil } +// ReorderCategories met à jour les positions selon l'ordre du tableau d'IDs fourni. +func (d *Database) ReorderCategories(ids []int) error { + tx := d.GDB.Begin() + for i, id := range ids { + if err := tx.Model(&Category{}).Where("id = ?", id).Update("position", i+1).Error; err != nil { + tx.Rollback() + return err + } + } + return tx.Commit().Error +} + func (d *Database) CategoryExists(name string) (bool, error) { var count int64 err := d.GDB.Model(&Category{}).Where("name = ?", name).Count(&count).Error diff --git a/backend/gestion/db/db_clients.go b/backend/gestion/db/db_clients.go index ff8d477f..fe652c92 100644 --- a/backend/gestion/db/db_clients.go +++ b/backend/gestion/db/db_clients.go @@ -32,6 +32,7 @@ func (d *Database) CreateClient(client *models.Client) error { return nil } +// GetClientByID récupère un client par son ID func (d *Database) GetClientByID(id int) (*models.Client, error) { var row struct { ID int `gorm:"column:id"` @@ -75,6 +76,7 @@ func (d *Database) GetClientByID(id int) (*models.Client, error) { return client, nil } +// GetAllClients récupère tous les clients func (d *Database) GetAllClients() ([]*models.Client, error) { var rows []struct { ID int `gorm:"column:id"` @@ -290,6 +292,28 @@ func (d *Database) GetClientByTelephone(telephone string) (*models.Client, error } // GetClientByUsername récupère un client par son username +// GetClientsByUsernames charge plusieurs clients en une seule requête. +// Retourne map[username]*Client ; les usernames sans correspondance sont absents de la map. +func (d *Database) GetClientsByUsernames(usernames []string) (map[string]*models.Client, error) { + result := make(map[string]*models.Client, len(usernames)) + if len(usernames) == 0 { + return result, nil + } + var rows []struct { + ID int `gorm:"column:id"` + Username string `gorm:"column:username"` + Nom string `gorm:"column:nom"` + Prenom string `gorm:"column:prenom"` + } + if err := d.GDB.Raw(`SELECT id, username, nom, prenom FROM clients WHERE username IN ?`, usernames).Scan(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + result[r.Username] = &models.Client{ID: r.ID, Username: r.Username, Nom: r.Nom, Prenom: r.Prenom} + } + return result, nil +} + func (d *Database) GetClientByUsername(username string) (*models.Client, error) { var row struct { ID int `gorm:"column:id"` @@ -713,52 +737,81 @@ func (d *Database) GetClientPointsAndRewards(username string) (pointsExtra map[s return pointsExtra, pointsRedeemed, nil } +// claimPoolRewardTx vérifie l'éligibilité et consomme une récompense pour un +// pool donné, dans la transaction fournie — factorisée pour être appelée +// seule (ClaimPoolReward) ou combinée avec la livraison du produit dans la +// même transaction (ClaimPoolRewardAndAddToBasket), afin qu'une récompense +// ne soit jamais consommée sans que son produit soit effectivement livré. +func claimPoolRewardTx(tx *gorm.DB, username, poolKey string, threshold int) (remainingAvailable int, err error) { + var row struct { + Points int `gorm:"column:pts"` + Redeemed int `gorm:"column:redeemed"` + } + if err := tx.Raw(` + SELECT + COALESCE((points_extra->>?)::int, 0) as pts, + COALESCE((points_redeemed->>?)::int, 0) as redeemed + FROM clients WHERE username = ? FOR UPDATE`, + poolKey, poolKey, username).Scan(&row).Error; err != nil { + return 0, fmt.Errorf("erreur lecture: %w", err) + } + + earned := row.Points / threshold + available := earned - row.Redeemed + if available <= 0 { + return 0, fmt.Errorf("pas de récompense disponible pour ce pool") + } + + if err := tx.Exec(` + UPDATE clients + SET points_redeemed = jsonb_set( + COALESCE(points_redeemed, '{}'::jsonb), + ARRAY[?], + to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1) + ), updated_at = CURRENT_TIMESTAMP + WHERE username = ?`, + poolKey, poolKey, username).Error; err != nil { + return 0, err + } + + return earned - (row.Redeemed + 1), nil +} + // ClaimPoolReward réclame une récompense pour un pool donné si le client a assez de points. // Retourne le nombre de récompenses disponibles restantes après la réclamation. func (d *Database) ClaimPoolReward(username, poolKey string, threshold int) (remainingAvailable int, err error) { - var points, redeemed int - err = d.GDB.Transaction(func(tx *gorm.DB) error { - var row struct { - Points int `gorm:"column:pts"` - Redeemed int `gorm:"column:redeemed"` - } - if err := tx.Raw(` - SELECT - COALESCE((points_extra->>?)::int, 0) as pts, - COALESCE((points_redeemed->>?)::int, 0) as redeemed - FROM clients WHERE username = ? FOR UPDATE`, - poolKey, poolKey, username).Scan(&row).Error; err != nil { - return fmt.Errorf("erreur lecture: %w", err) - } - points = row.Points - redeemed = row.Redeemed - - earned := points / threshold - available := earned - redeemed - if available <= 0 { - return fmt.Errorf("pas de récompense disponible pour ce pool") - } - - return tx.Exec(` - UPDATE clients - SET points_redeemed = jsonb_set( - COALESCE(points_redeemed, '{}'::jsonb), - ARRAY[?], - to_jsonb(COALESCE((points_redeemed->>?)::int, 0) + 1) - ), updated_at = CURRENT_TIMESTAMP - WHERE username = ?`, - poolKey, poolKey, username).Error + var err error + remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold) + return err }) if err != nil { return 0, err } - - earned := points / threshold - remainingAvailable = earned - (redeemed + 1) return remainingAvailable, nil } +func (d *Database) ClaimPoolRewardAndAddToBasket(username, poolKey string, threshold int, items []models.RewardItem) (remainingAvailable int, added []models.Panier, err error) { + err = d.GDB.Transaction(func(tx *gorm.DB) error { + var err error + remainingAvailable, err = claimPoolRewardTx(tx, username, poolKey, threshold) + if err != nil { + return err + } + if len(items) > 0 { + added, err = addRewardsToBasketTx(tx, username, items, poolKey) + if err != nil { + return err + } + } + return nil + }) + if err != nil { + return 0, nil, err + } + return remainingAvailable, added, nil +} + // ResetClientRedeemed remet à zéro les récompenses réclamées (admin). func (d *Database) ResetClientRedeemed(username, poolKey string) error { if poolKey != "" { diff --git a/backend/gestion/db/db_command_items.go b/backend/gestion/db/db_command_items.go index ed5e9865..87a4db3d 100644 --- a/backend/gestion/db/db_command_items.go +++ b/backend/gestion/db/db_command_items.go @@ -6,8 +6,37 @@ import ( "slices" "strings" "time" + + "gorm.io/gorm" ) +// commandItemFull mappe toutes les colonnes de command_items pour les insertions batch avec infos client. +type commandItemFull struct { + CommandID int `gorm:"column:command_id"` + Produit string `gorm:"column:produit"` + ProductID int `gorm:"column:product_id"` + Quantite float64 `gorm:"column:quantite"` + Prix float64 `gorm:"column:prix"` + IsReward bool `gorm:"column:is_reward"` + RewardPoolKey string `gorm:"column:reward_pool_key"` + ClientUsername string `gorm:"column:client_username"` + ClientNom string `gorm:"column:client_nom"` + ClientPrenom string `gorm:"column:client_prenom"` + ClientTelephone string `gorm:"column:client_telephone"` + DeliveryAddress string `gorm:"column:delivery_address"` + Status string `gorm:"column:status"` +} + +func (commandItemFull) TableName() string { return "command_items" } + +// InsertCommandItemsBatch insère plusieurs items en une seule requête. +func (d *Database) InsertCommandItemsBatch(items []commandItemFull) error { + if len(items) == 0 { + return nil + } + return d.GDB.Create(&items).Error +} + // ============================================ // VALIDATION HELPERS // ============================================ @@ -308,6 +337,92 @@ func (d *Database) GetCommandItems(commandID int) ([]map[string]any, error) { return items, nil } +// GetCommandItemsBatch charge les items de plusieurs commandes en une seule requête. +// Retourne map[commandID][]items, même structure que GetCommandItems. +func (d *Database) GetCommandItemsBatch(commandIDs []int) (map[int][]map[string]any, error) { + result := make(map[int][]map[string]any, len(commandIDs)) + if len(commandIDs) == 0 { + return result, nil + } + + var rows []struct { + ID int `gorm:"column:id"` + CommandID int `gorm:"column:command_id"` + Produit string `gorm:"column:produit"` + ProductID *int64 `gorm:"column:product_id"` + Quantite float64 `gorm:"column:quantite"` + Prix float64 `gorm:"column:prix"` + IsReward bool `gorm:"column:is_reward"` + RewardPoolKey string `gorm:"column:reward_pool_key"` + ClientUsername string `gorm:"column:client_username"` + ClientNom string `gorm:"column:client_nom"` + ClientPrenom string `gorm:"column:client_prenom"` + ClientTelephone string `gorm:"column:client_telephone"` + DeliveryAddress *string `gorm:"column:delivery_address"` + Status *string `gorm:"column:status"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` + CommandStatus *string `gorm:"column:command_status"` + CommandAddress *string `gorm:"column:command_address"` + TotalPrix float64 `gorm:"column:total_prix"` + ReferralUsed float64 `gorm:"column:referral_used"` + LivreurAssign *string `gorm:"column:livreur_assign"` + CommandCreatedAt *time.Time `gorm:"column:command_created_at"` + Category string `gorm:"column:category"` + Unit string `gorm:"column:unit"` + ClientOrderNumber int `gorm:"column:client_order_number"` + } + + err := d.GDB.Raw(` + SELECT + ci.id, ci.command_id, ci.produit, ci.product_id, + ci.quantite, ci.prix, ci.is_reward, ci.reward_pool_key, + ci.client_username, ci.client_nom, ci.client_prenom, ci.client_telephone, + ci.delivery_address, ci.status, ci.created_at, ci.updated_at, + c.status as command_status, c.adresse as command_address, + c.total_prix, c.referral_used, c.livreur_assign, + c.created_at as command_created_at, + COALESCE(p.category, '') as category, + COALESCE(p.unit, '') as unit, + c.client_order_id as client_order_number + FROM command_items ci + LEFT JOIN commandes c ON ci.command_id = c.id + LEFT JOIN products p ON ci.product_id = p.id + WHERE ci.command_id IN ? + ORDER BY ci.command_id ASC, ci.id ASC`, commandIDs).Scan(&rows).Error + if err != nil { + return nil, fmt.Errorf("erreur récupération items batch: %w", err) + } + + for _, row := range rows { + productIDValue := 0 + if row.ProductID != nil { + productIDValue = int(*row.ProductID) + } + var commandCreatedAt any + if row.CommandCreatedAt != nil { + commandCreatedAt = *row.CommandCreatedAt + } + item := map[string]any{ + "id": row.ID, "command_id": row.CommandID, + "produit": row.Produit, "product_id": productIDValue, + "quantite": row.Quantite, "prix": row.Prix, + "is_reward": row.IsReward, "reward_pool_key": row.RewardPoolKey, + "client_username": row.ClientUsername, "client_nom": row.ClientNom, + "client_prenom": row.ClientPrenom, "client_telephone": row.ClientTelephone, + "delivery_address": ptrStr(row.DeliveryAddress), "status": ptrStr(row.Status), + "created_at": row.CreatedAt, "updated_at": row.UpdatedAt, + "command_status": ptrStr(row.CommandStatus), "command_address": ptrStr(row.CommandAddress), + "total_prix": row.TotalPrix, "referral_used": row.ReferralUsed, + "livreur_assign": ptrStr(row.LivreurAssign), "command_created_at": commandCreatedAt, + "category": row.Category, "unit": row.Unit, + "client_order_number": row.ClientOrderNumber, + } + result[row.CommandID] = append(result[row.CommandID], item) + } + return result, nil +} + // ptrStr retourne la valeur d'un *string ou "" si nil func ptrStr(s *string) string { if s == nil { @@ -316,6 +431,12 @@ func ptrStr(s *string) string { return *s } +// DeleteCommandItem supprime un item d'une commande et restaure son stock si +// la commande n'est pas déjà dans un état terminal. Le statut de la commande +// est verrouillé (FOR UPDATE) avant toute décision, dans la même transaction +// que la suppression et le remboursement, pour éviter une course avec une +// annulation concurrente de la commande entière (qui rembourserait déjà cet +// item) — même classe de bug que celle corrigée sur UpdateCommandStatusAdmin. func (d *Database) DeleteCommandItem(commandID, itemID int) error { log.Printf("🗑️ [DeleteCommandItem] START - commandID=%d, itemID=%d", commandID, itemID) @@ -326,61 +447,55 @@ func (d *Database) DeleteCommandItem(commandID, itemID int) error { return err } - var result struct { - Prix float64 `gorm:"column:prix"` - Quantite float64 `gorm:"column:quantite"` - ProductID int `gorm:"column:product_id"` - } - if err := d.GDB.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil { - return fmt.Errorf("erreur vérification item: %w", err) - } - if result.Prix == 0 && result.Quantite == 0 { - return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID) - } - - var cmdStatus string - d.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&cmdStatus) - - noRestoreStatuses := []string{"cancelled", "approved", "livre"} - restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus) - - tx := d.GDB.Begin() - if tx.Error != nil { - return fmt.Errorf("erreur démarrage transaction: %w", tx.Error) - } - - if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil { - tx.Rollback() - log.Printf("❌ Erreur DELETE command_items: %v", err) - return fmt.Errorf("erreur suppression item: %w", err) - } - - if err := tx.Exec( - `UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`, - result.Prix*result.Quantite, commandID, - ).Error; err != nil { - tx.Rollback() - log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err) - return fmt.Errorf("erreur mise à jour total commande: %w", err) - } - - if restoreStock { - if err := tx.Exec( - `UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, - result.Quantite, result.ProductID, - ).Error; err != nil { - tx.Rollback() - log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err) - return fmt.Errorf("erreur restauration stock: %w", err) + return d.GDB.Transaction(func(tx *gorm.DB) error { + var cmdStatus string + if err := tx.Raw(`SELECT status FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdStatus).Error; err != nil { + return fmt.Errorf("erreur vérification commande: %w", err) + } + if cmdStatus == "" { + return fmt.Errorf("commande %d non trouvée", commandID) } - log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID) - } - if err := tx.Commit().Error; err != nil { - return fmt.Errorf("erreur commit transaction: %w", err) - } + var result struct { + Prix float64 `gorm:"column:prix"` + Quantite float64 `gorm:"column:quantite"` + ProductID int `gorm:"column:product_id"` + } + if err := tx.Raw(`SELECT prix, quantite, product_id FROM command_items WHERE id = ? AND command_id = ?`, itemID, commandID).Scan(&result).Error; err != nil { + return fmt.Errorf("erreur vérification item: %w", err) + } + if result.Prix == 0 && result.Quantite == 0 { + return fmt.Errorf("item %d non trouvé dans la commande %d", itemID, commandID) + } - return nil + if err := tx.Exec(`DELETE FROM command_items WHERE id = ?`, itemID).Error; err != nil { + log.Printf("❌ Erreur DELETE command_items: %v", err) + return fmt.Errorf("erreur suppression item: %w", err) + } + + if err := tx.Exec( + `UPDATE commandes SET total_prix = GREATEST(0, total_prix - ?) WHERE id = ?`, + result.Prix*result.Quantite, commandID, + ).Error; err != nil { + log.Printf("❌ [DeleteCommandItem] Erreur maj total commande: %v", err) + return fmt.Errorf("erreur mise à jour total commande: %w", err) + } + + noRestoreStatuses := []string{"cancelled", "approved", "livre"} + restoreStock := result.ProductID != 0 && !slices.Contains(noRestoreStatuses, cmdStatus) + if restoreStock { + if err := tx.Exec( + `UPDATE products SET stock = stock + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, + result.Quantite, result.ProductID, + ).Error; err != nil { + log.Printf("❌ [DeleteCommandItem] Erreur restauration stock: %v", err) + return fmt.Errorf("erreur restauration stock: %w", err) + } + log.Printf("✅ [DeleteCommandItem] Stock restauré: +%.3f pour produit %d", result.Quantite, result.ProductID) + } + + return nil + }) } func (d *Database) UpdateCommandItemStatus(itemID int, status string) error { diff --git a/backend/gestion/db/db_commands.go b/backend/gestion/db/db_commands.go index 4d6728fa..a1be038e 100644 --- a/backend/gestion/db/db_commands.go +++ b/backend/gestion/db/db_commands.go @@ -1,6 +1,8 @@ package db import ( + "encoding/json" + "errors" "fmt" "gestion/models" "log" @@ -11,6 +13,9 @@ import ( "gorm.io/gorm" ) +// errAlreadyApproved est retournée quand le client tente d'approuver une commande déjà approuvée. +var errAlreadyApproved = errors.New("already_approved") + func sanitizeString(s string) string { sanitized := strings.Map(func(r rune) rune { if r < 32 || r == 127 { @@ -52,18 +57,6 @@ type basketItem struct { RewardPoolKey string `gorm:"column:reward_pool_key"` } -func (d *Database) fetchBasketItems(username string) ([]basketItem, float64, error) { - var items []basketItem - if err := d.GDB.Table("baskets").Select("product_id, quantity, price, is_reward, reward_pool_key").Where("username = ?", username).Scan(&items).Error; err != nil { - return nil, 0, fmt.Errorf("erreur récupération panier: %w", err) - } - total := 0.0 - for _, item := range items { - total += item.Price - } - return items, total, nil -} - // validateCommandStatus vérifie si le statut est valide func validateCommandStatus(status string) error { validStatuses := map[string]bool{ @@ -84,74 +77,6 @@ func validateCommandStatus(status string) error { return nil } -func (d *Database) CreateCommand(username string) (*models.Command, error) { - adresse := "Adresse non spécifiée" - var clientCheck models.Client - if err := d.GDB.Select("username").Where("username = ?", username).First(&clientCheck).Error; err == nil && clientCheck.Username != "" { - adresse = clientCheck.Username - } - - basketItems, totalPrix, err := d.fetchBasketItems(username) - if err != nil { - return nil, err - } - - if len(basketItems) == 0 { - return nil, fmt.Errorf("le panier est vide") - } - - var cmdResult struct { - ID int `gorm:"column:id"` - ClientOrderID int `gorm:"column:client_order_id"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` - } - err = d.GDB.Raw(` - INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at) - VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - RETURNING id, client_order_id, created_at, updated_at`, - username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error - if err != nil { - return nil, fmt.Errorf("erreur lors de la création de la commande: %w", err) - } - - commandID := cmdResult.ID - - for _, item := range basketItems { - productName, err := d.GetProductNameByID(item.ProductID) - if err != nil { - productName = "Produit inconnu" - } - - cmdItem := models.CommandItem{ - CommandID: commandID, - Produit: productName, - ProductID: item.ProductID, - Quantity: item.Quantity, - Price: item.Price, - IsReward: item.IsReward, - RewardPoolKey: item.RewardPoolKey, - } - if err := d.GDB.Create(&cmdItem).Error; err != nil { - return nil, fmt.Errorf("erreur lors de l'insertion des items: %w", err) - } - - } - - if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil { - return nil, fmt.Errorf("erreur lors du vidage du panier: %w", err) - } - - command := &models.Command{ - ID: commandID, - ClientOrderID: cmdResult.ClientOrderID, - Status: "pending", - Total: totalPrix, - } - - return command, nil -} - func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) { if err := validateUsername(username); err != nil { return nil, err @@ -175,92 +100,122 @@ func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (* clientTelephone = sanitizeString(client.Telephone) } - basketItems, totalPrix, err := d.fetchBasketItems(username) + var ( + command *models.Command + totalPrix float64 + ) + err = d.GDB.Transaction(func(tx *gorm.DB) error { + // Verrou sur le panier : un double-submit concurrent du même client se + // bloque ici puis échoue proprement ("panier vide") une fois le premier + // passage terminé, au lieu de créer une commande fantôme. + var basketItems []basketItem + if err := tx.Raw(`SELECT product_id, quantity, price, is_reward, reward_pool_key FROM baskets WHERE username = ? FOR UPDATE`, username).Scan(&basketItems).Error; err != nil { + return fmt.Errorf("erreur récupération panier: %w", err) + } + if len(basketItems) == 0 { + return fmt.Errorf("le panier est vide") + } + + for _, item := range basketItems { + if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 { + return fmt.Errorf("données panier invalides") + } + totalPrix += item.Price + } + + if totalPrix <= 0 || totalPrix > 100000 { + return fmt.Errorf("montant de commande invalide: %.2f€", totalPrix) + } + + var cmdResult struct { + ID int `gorm:"column:id"` + ClientOrderID int `gorm:"column:client_order_id"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` + } + if err := tx.Raw(` + INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at) + VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + RETURNING id, client_order_id, created_at, updated_at`, + username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error; err != nil { + return fmt.Errorf("erreur création commande: %w", err) + } + commandID := cmdResult.ID + + productIDs2 := make([]int, 0, len(basketItems)) + for _, item := range basketItems { + productIDs2 = append(productIDs2, item.ProductID) + } + productNames2, _ := d.GetProductNamesByIDs(productIDs2) + + batchItems := make([]commandItemFull, 0, len(basketItems)) + for _, item := range basketItems { + productName := productNames2[item.ProductID] + if productName == "" { + productName = fmt.Sprintf("Produit #%d", item.ProductID) + } + batchItems = append(batchItems, commandItemFull{ + CommandID: commandID, + Produit: productName, + ProductID: item.ProductID, + Quantite: item.Quantity, + Prix: item.Price, + IsReward: item.IsReward, + RewardPoolKey: item.RewardPoolKey, + ClientUsername: username, + ClientNom: clientNom, + ClientPrenom: clientPrenom, + ClientTelephone: clientTelephone, + DeliveryAddress: deliveryAddress, + Status: "pending", + }) + } + if err := tx.Create(&batchItems).Error; err != nil { + return fmt.Errorf("erreur insertion items: %w", err) + } + + // Les articles récompense (payés en points) restent des produits physiques + // réellement distribués : le stock doit être décrémenté comme pour un + // article payant. + for _, item := range basketItems { + var currentStock float64 + if err := tx.Raw(`SELECT stock FROM products WHERE id = ? FOR UPDATE`, item.ProductID).Scan(¤tStock).Error; err != nil { + return fmt.Errorf("erreur lecture stock produit %d: %w", item.ProductID, err) + } + if currentStock < item.Quantity { + return fmt.Errorf("stock insuffisant pour le produit %d", item.ProductID) + } + if err := tx.Exec(`UPDATE products SET stock = stock - ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, item.Quantity, item.ProductID).Error; err != nil { + return fmt.Errorf("erreur décrémentation stock produit %d: %w", item.ProductID, err) + } + } + if err := tx.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil { + return err + } + + command = &models.Command{ + ID: commandID, + ClientOrderID: cmdResult.ClientOrderID, + Username: username, + Status: "pending", + Total: totalPrix, + DeliveryAddress: deliveryAddress, + CreatedAt: cmdResult.CreatedAt, + UpdatedAt: cmdResult.UpdatedAt, + } + return nil + }) if err != nil { - log.Printf("❌ Erreur query basket: %v", err) + log.Printf("❌ Erreur création commande: %v", err) return nil, err } - if len(basketItems) == 0 { - return nil, fmt.Errorf("le panier est vide") - } - - for _, item := range basketItems { - if item.ProductID <= 0 || item.Quantity <= 0 || item.Price < 0 { - return nil, fmt.Errorf("données panier invalides") - } - } - - if totalPrix <= 0 || totalPrix > 100000 { - return nil, fmt.Errorf("montant de commande invalide: %.2f€", totalPrix) - } - - var cmdResult struct { - ID int `gorm:"column:id"` - ClientOrderID int `gorm:"column:client_order_id"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` - } - err = d.GDB.Raw(` - INSERT INTO commandes (username, status, adresse, total_prix, client_order_id, created_at, updated_at) - VALUES (?, ?, ?, ?, (SELECT COALESCE(MAX(client_order_id), 0) + 1 FROM commandes WHERE username = ?), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - RETURNING id, client_order_id, created_at, updated_at`, - username, "pending", deliveryAddress, totalPrix, username).Scan(&cmdResult).Error - if err != nil { - return nil, fmt.Errorf("erreur création commande: %w", err) - } - - commandID := cmdResult.ID - - for _, item := range basketItems { - productName, err := d.GetProductNameByID(item.ProductID) - if err != nil || productName == "" { - productName = fmt.Sprintf("Produit #%d", item.ProductID) - } - - err = d.InsertCommandItemWithClientInfo( - commandID, - productName, - item.ProductID, - item.Quantity, - item.Price, - item.IsReward, - item.RewardPoolKey, - username, - clientNom, - clientPrenom, - clientTelephone, - deliveryAddress, - ) - if err != nil { - log.Printf("❌ Erreur INSERT command_items: %v", err) - return nil, fmt.Errorf("erreur insertion items: %w", err) - } - - // Stock déjà déduit à l'ajout au panier — ne pas déduire une seconde fois ici. - } - - if err := d.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username).Error; err != nil { - log.Printf("⚠️ Erreur vidage panier: %v", err) - } - sanitizedAddress := sanitizeLogMessage(deliveryAddress) - d.AddCommandLog(commandID, "created", + d.AddCommandLog(command.ID, "created", fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s", sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)), username) - command := &models.Command{ - ID: commandID, - ClientOrderID: cmdResult.ClientOrderID, - Username: username, - Status: "pending", - Total: totalPrix, - DeliveryAddress: deliveryAddress, - CreatedAt: cmdResult.CreatedAt, - UpdatedAt: cmdResult.UpdatedAt, - } - return command, nil } @@ -424,9 +379,28 @@ func (d *Database) GetCommandByID(id int) (map[string]any, error) { return command, nil } +const lastDeliveryCoordsCacheTTL = 5 * time.Minute + +func lastDeliveryCoordsCacheKey(livreurUsername string) string { + return fmt.Sprintf("livreur:last_delivery_coords:%s", livreurUsername) +} + // GetLastDeliveryCoords retourne les coordonnées GPS de la dernière livraison terminée d'un livreur. -// Utilisé comme fallback quand le GPS temps réel est indisponible. +// Utilisé comme fallback quand le GPS temps réel est indisponible. Mis en cache quelques minutes +// car appelé à chaque calcul d'ETA et la dernière livraison ne change pas souvent. func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float64, error) { + cacheKey := lastDeliveryCoordsCacheKey(livreurUsername) + + if cached, err := Redis.Get(RedisCtx, cacheKey).Result(); err == nil { + var coords struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + } + if jsonErr := json.Unmarshal([]byte(cached), &coords); jsonErr == nil { + return coords.Lat, coords.Lon, nil + } + } + var result struct { DestLatitude float64 `gorm:"column:dest_latitude"` DestLongitude float64 `gorm:"column:dest_longitude"` @@ -446,6 +420,10 @@ func (d *Database) GetLastDeliveryCoords(livreurUsername string) (float64, float return 0, 0, fmt.Errorf("coordonnées introuvables pour dernière livraison de %s", livreurUsername) } + if coordsJSON, err := json.Marshal(map[string]float64{"lat": result.DestLatitude, "lon": result.DestLongitude}); err == nil { + Redis.Set(RedisCtx, cacheKey, coordsJSON, lastDeliveryCoordsCacheTTL) + } + return result.DestLatitude, result.DestLongitude, nil } @@ -772,6 +750,11 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s return fmt.Errorf("cette commande ne vous appartient pas") } + if cmd.Status == "approved" { + log.Printf("ℹ️ [ApproveAtomic] Commande %d déjà approuvée — réponse idempotente", commandID) + return errAlreadyApproved + } + if cmd.Status != "livre" { log.Printf("❌ [ApproveAtomic] Statut invalide: %s (attendu: livre)", cmd.Status) return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status) @@ -821,6 +804,9 @@ func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, s }) if err != nil { + if errors.Is(err, errAlreadyApproved) { + return 0, "", nil + } return 0, "", err } diff --git a/backend/gestion/db/db_delivery.go b/backend/gestion/db/db_delivery.go index 288a4652..fbb46a85 100644 --- a/backend/gestion/db/db_delivery.go +++ b/backend/gestion/db/db_delivery.go @@ -95,7 +95,6 @@ func (d *Database) AssignDeliveryPerson(commandID int, livreurUsername string) e }) } -// GetDeliveryPersonCommands récupère les commandes assignées à un livreur func (d *Database) GetDeliveryPersonCommands(livreurUsername string, status string) ([]map[string]any, error) { query := `SELECT id, username, status, adresse, total_prix::float8 as total_prix, livreur_assign, created_at, updated_at FROM commandes diff --git a/backend/gestion/db/db_gps_link.go b/backend/gestion/db/db_gps_link.go index 3a33b86c..40a2cddd 100644 --- a/backend/gestion/db/db_gps_link.go +++ b/backend/gestion/db/db_gps_link.go @@ -25,20 +25,16 @@ func wazeAppLink(lat, lon float64) string { return fmt.Sprintf("waze://?ll=%.6f,%.6f&navigate=yes", lat, lon) } -// GenerateMapLinks génère tous les liens de cartes pour une position GPS func (d *Database) GenerateMapLinks(lat, lon float64, label string) MapLinks { return MapLinks{ WazeApp: wazeAppLink(lat, lon), } } -// GenerateNavigationLink génère un lien de navigation vers une destination -// fromLat/fromLon sont ignorés : Waze part toujours de la position GPS courante func (d *Database) GenerateNavigationLink(fromLat, fromLon, toLat, toLon float64, platform string) string { return wazeAppLink(toLat, toLon) } -// GenerateMapLinksForCommand génère les liens de navigation pour une commande func (d *Database) GenerateMapLinksForCommand(commandID int, deliverymanUsername string) (map[string]string, error) { _, _, err := d.GetDeliveryPersonLocation(deliverymanUsername) if err != nil { diff --git a/backend/gestion/db/db_init.go b/backend/gestion/db/db_init.go index 12b50c2f..c3f0d7e6 100644 --- a/backend/gestion/db/db_init.go +++ b/backend/gestion/db/db_init.go @@ -53,7 +53,7 @@ func InitDB() *Database { // Configuration du pool de connexions db.SetMaxOpenConns(50) db.SetMaxIdleConns(10) - db.SetConnMaxLifetime(5 * time.Minute) + db.SetConnMaxLifetime(30 * time.Minute) // Tester la connexion if err = db.Ping(); err != nil { @@ -66,7 +66,9 @@ func InitDB() *Database { gormDB, err := gorm.Open(postgres.New(postgres.Config{ Conn: db, }), &gorm.Config{ - Logger: logger.Default.LogMode(logger.Silent), + SkipDefaultTransaction: true, + PrepareStmt: true, + Logger: logger.Default.LogMode(logger.Silent), }) if err != nil { log.Fatalf("❌ Erreur initialisation GORM: %v", err) @@ -165,6 +167,23 @@ func InitDB() *Database { log.Fatalf("❌ Erreur migration categories.is_coming_soon: %v", err) } + // Migration: position d'affichage des catégories + if _, err = database.Exec(`ALTER TABLE categories ADD COLUMN IF NOT EXISTS position INTEGER NOT NULL DEFAULT 0`); err != nil { + log.Fatalf("❌ Erreur migration categories.position: %v", err) + } + // Backfill: attribuer des positions aux catégories existantes (ordre alphabétique) + if _, err = database.Exec(` + UPDATE categories c + SET position = sub.rn + FROM ( + SELECT id, ROW_NUMBER() OVER (ORDER BY name ASC) AS rn + FROM categories + ) sub + WHERE c.id = sub.id AND c.position = 0 + `); err != nil { + log.Fatalf("❌ Erreur backfill categories.position: %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, @@ -277,6 +296,21 @@ func InitDB() *Database { log.Fatalf("❌ Erreur migration contacts: %v", err) } + // Migration: clé RustFS pour les médias (stockage objet) + if _, err = database.Exec(`ALTER TABLE media ADD COLUMN IF NOT EXISTS key TEXT NOT NULL DEFAULT ''`); err != nil { + log.Fatalf("❌ Erreur migration media.key: %v", err) + } + + // Migration: colonne parrain sur les clients (système de parrainage) + if _, err = database.Exec(`ALTER TABLE clients ADD COLUMN IF NOT EXISTS parrain VARCHAR(255) DEFAULT NULL`); err != nil { + log.Fatalf("❌ Erreur migration clients.parrain: %v", err) + } + + // Migration: index sur clients.parrain (lookups filleuls + stats parrainage) + if _, err = database.Exec(`CREATE INDEX IF NOT EXISTS idx_clients_parrain ON clients(parrain) WHERE parrain IS NOT NULL`); err != nil { + log.Fatalf("❌ Erreur migration idx_clients_parrain: %v", err) + } + // Lancer le nettoyage périodique des tokens expirés go database.cleanExpiredTokensPeriodically() @@ -321,6 +355,7 @@ func (db *Database) createTables() error { cancellations_count INTEGER DEFAULT 0 NOT NULL, last_penalty_reason TEXT DEFAULT NULL, referral_balance NUMERIC(10,2) DEFAULT 0.0, + parrain VARCHAR(255) DEFAULT NULL, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );`, @@ -382,6 +417,7 @@ func (db *Database) createTables() error { product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE, url TEXT NOT NULL, type VARCHAR(50) NOT NULL, + key TEXT NOT NULL DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );`, @@ -513,6 +549,30 @@ func (db *Database) createTables() error { id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL );`, + + // ============================ + // TABLE livreur_ratings + // ============================ + `CREATE TABLE IF NOT EXISTS livreur_ratings ( + id SERIAL PRIMARY KEY, + order_id INTEGER NOT NULL UNIQUE REFERENCES commandes(id) ON DELETE CASCADE, + livreur_username VARCHAR(255) NOT NULL, + client_username VARCHAR(255) NOT NULL, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + comment TEXT NOT NULL DEFAULT '', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE INDEX IF NOT EXISTS idx_ratings_livreur ON livreur_ratings(livreur_username);`, + + // ============================ + // TABLE login_history (livreur uniquement) + // ============================ + `CREATE TABLE IF NOT EXISTS login_history ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + );`, + `CREATE INDEX IF NOT EXISTS idx_login_history_username ON login_history(username);`, } for _, query := range queries { diff --git a/backend/gestion/db/db_livreur_rating.go b/backend/gestion/db/db_livreur_rating.go new file mode 100644 index 00000000..1cbfa1ef --- /dev/null +++ b/backend/gestion/db/db_livreur_rating.go @@ -0,0 +1,62 @@ +package db + +import ( + "time" +) + +type LivreurRating struct { + ID int `json:"id"` + OrderID int `json:"order_id"` + LivreurUsername string `json:"livreur_username"` + ClientUsername string `json:"client_username"` + Rating int `json:"rating"` + Comment string `json:"comment"` + CreatedAt time.Time `json:"created_at"` +} + +func (d *Database) SubmitLivreurRating(orderID int, livreurUsername, clientUsername string, rating int, comment string) error { + return d.GDB.Exec(` + INSERT INTO livreur_ratings (order_id, livreur_username, client_username, rating, comment, created_at) + VALUES (?, ?, ?, ?, ?, NOW()) + `, orderID, livreurUsername, clientUsername, rating, comment).Error +} + +func (d *Database) GetOrderRating(orderID int) (*LivreurRating, error) { + var r LivreurRating + err := d.GDB.Raw(`SELECT * FROM livreur_ratings WHERE order_id = ? LIMIT 1`, orderID).Scan(&r).Error + if err != nil { + return nil, err + } + if r.ID == 0 { + return nil, nil + } + return &r, nil +} + +func (d *Database) GetLivreurRatings(livreurUsername string) ([]LivreurRating, float64, error) { + var ratings []LivreurRating + if err := d.GDB.Raw(` + SELECT * FROM livreur_ratings WHERE livreur_username = ? ORDER BY created_at DESC LIMIT 200 + `, livreurUsername).Scan(&ratings).Error; err != nil { + return nil, 0, err + } + + var avg float64 + if len(ratings) > 0 { + d.GDB.Raw(`SELECT COALESCE(AVG(rating), 0) FROM livreur_ratings WHERE livreur_username = ?`, livreurUsername).Scan(&avg) + } + return ratings, avg, nil +} + +// GetOrderForRating retourne l'username client et le livreur d'une commande approuvée +func (d *Database) GetOrderForRating(orderID int) (clientUsername, livreurUsername string, err error) { + var row struct { + Username string `gorm:"column:username"` + LivreurAssign string `gorm:"column:livreur_assign"` + } + err = d.GDB.Raw(` + SELECT username, COALESCE(livreur_assign, '') as livreur_assign + FROM commandes WHERE id = ? AND status = 'approved' LIMIT 1 + `, orderID).Scan(&row).Error + return row.Username, row.LivreurAssign, err +} diff --git a/backend/gestion/db/db_login_history.go b/backend/gestion/db/db_login_history.go new file mode 100644 index 00000000..238c3483 --- /dev/null +++ b/backend/gestion/db/db_login_history.go @@ -0,0 +1,34 @@ +package db + +import ( + "time" +) + +type LoginHistoryEntry struct { + ID int `json:"id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +// RecordLivreurLogin enregistre une connexion réussie d'un livreur (best-effort, non bloquant). +func (d *Database) RecordLivreurLogin(username string) error { + return d.GDB.Exec(` + INSERT INTO login_history (username, created_at) + VALUES (?, NOW()) + `, username).Error +} + +// GetLivreurLoginHistoryByMonth retourne le détail des connexions d'un livreur pour un mois donné, +// triées du plus récent au plus ancien (max 50 entrées). +func (d *Database) GetLivreurLoginHistoryByMonth(username string, year, month int) ([]LoginHistoryEntry, error) { + var entries []LoginHistoryEntry + err := d.GDB.Raw(` + SELECT id, username, created_at FROM login_history + WHERE username = ? + AND EXTRACT(YEAR FROM created_at) = ? + AND EXTRACT(MONTH FROM created_at) = ? + ORDER BY created_at DESC + LIMIT 50 + `, username, year, month).Scan(&entries).Error + return entries, err +} diff --git a/backend/gestion/db/db_media.go b/backend/gestion/db/db_media.go index 60548f18..0f2f2292 100644 --- a/backend/gestion/db/db_media.go +++ b/backend/gestion/db/db_media.go @@ -59,8 +59,8 @@ func validateMediaURL(url string) error { if strings.Contains(url, "..") || strings.Contains(url, "...") || strings.Contains(url, "..//") { return fmt.Errorf("path traversal détecté dans l'URL") } - if !strings.HasPrefix(url, "/uploads/") { - return fmt.Errorf("URL doit commencer par /uploads/") + if !strings.HasPrefix(url, "/uploads/") && !strings.HasPrefix(url, "/media/") { + return fmt.Errorf("URL doit commencer par /uploads/ ou /media/") } dangerousChars := []string{"<", ">", "\"", "'", ";", "|", "&", "$", "`", "\\"} for _, char := range dangerousChars { @@ -93,6 +93,11 @@ func (d *Database) CreateMedia(media any) error { mediaType := m.GetType() mediaURL := m.GetURL() + mediaKey := "" + if mediaPtr, isPtr := media.(*models.Media); isPtr { + mediaKey = mediaPtr.Key + } + if err := validateProductID(productID); err != nil { log.Printf("❌ [CreateMedia] %v", err) return err @@ -106,14 +111,14 @@ func (d *Database) CreateMedia(media any) error { return err } - if err := d.InsertMedia(m, productID, mediaURL, mediaType); err != nil { + if err := d.InsertMedia(m, productID, mediaURL, mediaType, mediaKey); err != nil { log.Printf("❌ [InsertMedia] %v", err) return err } return nil } -func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string) error { +func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType string, key string) error { var exists bool if err := d.GDB.Raw(`SELECT EXISTS(SELECT 1 FROM products WHERE id = ?)`, productID).Scan(&exists).Error; err != nil { log.Printf("❌ [CreateMedia] Erreur vérification produit: %v", err) @@ -128,9 +133,9 @@ func (d *Database) InsertMedia(m any, productID int, mediaURL any, mediaType str ID int `gorm:"column:id"` } err := d.GDB.Raw(` - INSERT INTO media (product_id, url, type, created_at) - VALUES (?, ?, ?, ?) RETURNING id`, - productID, mediaURL, mediaType, time.Now(), + INSERT INTO media (product_id, url, type, key, created_at) + VALUES (?, ?, ?, ?, ?) RETURNING id`, + productID, mediaURL, mediaType, key, time.Now(), ).Scan(&result).Error if err != nil { log.Printf("❌ [CreateMedia] Erreur INSERT: %v", err) @@ -154,7 +159,7 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) { var media models.Media err := d.GDB.Raw(` - SELECT id, product_id, url, type, created_at + SELECT id, product_id, url, type, key, created_at FROM media WHERE id = ?`, mediaID).Scan(&media).Error if err != nil { log.Printf("❌ [GetMediaByID] Erreur query: %v", err) @@ -169,6 +174,20 @@ func (d *Database) GetMediaByID(mediaID int) (*models.Media, error) { return &media, nil } +// GetMediaBatch charge les médias de plusieurs produits en une seule requête. +func (d *Database) GetMediaBatch(productIDs []int) map[int][]models.Media { + result := make(map[int][]models.Media, len(productIDs)) + if len(productIDs) == 0 { + return result + } + var mediaList []models.Media + d.GDB.Raw(`SELECT id, product_id, url, type, key, created_at FROM media WHERE product_id IN ? ORDER BY product_id ASC, id ASC`, productIDs).Scan(&mediaList) + for _, m := range mediaList { + result[m.ProductID] = append(result[m.ProductID], m) + } + return result +} + func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) { log.Printf("🖼️ [GetMediaByProductID] START - ProductID=%d", productID) @@ -179,7 +198,7 @@ func (d *Database) GetMediaByProductID(productID int) ([]models.Media, error) { var mediaList []models.Media err := d.GDB.Raw(` - SELECT id, product_id, url, type, created_at + SELECT id, product_id, url, type, key, created_at FROM media WHERE product_id = ? ORDER BY id ASC`, productID).Scan(&mediaList).Error if err != nil { diff --git a/backend/gestion/db/db_notifications.go b/backend/gestion/db/db_notifications.go index fef89dde..c887a777 100644 --- a/backend/gestion/db/db_notifications.go +++ b/backend/gestion/db/db_notifications.go @@ -36,7 +36,7 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa pipe := Redis.Pipeline() pipe.LPush(RedisCtx, notifKey, notifJSON) pipe.LTrim(RedisCtx, notifKey, 0, 199) - pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour) + pipe.Expire(RedisCtx, notifKey, time.Hour) pipe.Exec(RedisCtx) //nolint if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { @@ -54,7 +54,6 @@ func (d *Database) NotifyClient(username string, commandID int, notifType, messa return nil } -// NotifyLivreur envoie une notification in-app (Redis) à un livreur func (d *Database) NotifyLivreur(username string, commandID int, notifType, message string) error { notifKey := fmt.Sprintf("notifications:%s", username) @@ -70,7 +69,7 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess pipe2 := Redis.Pipeline() pipe2.LPush(RedisCtx, notifKey, notifJSON) pipe2.LTrim(RedisCtx, notifKey, 0, 199) - pipe2.Expire(RedisCtx, notifKey, 7*24*time.Hour) + pipe2.Expire(RedisCtx, notifKey, time.Hour) pipe2.Exec(RedisCtx) //nolint if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { @@ -88,7 +87,6 @@ func (d *Database) NotifyLivreur(username string, commandID int, notifType, mess return nil } -// NotifyAllAdminCabine stocke une notification Redis pour tous les admins/cabines func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryAddr string) { var users []struct { Username string `gorm:"column:username"` @@ -114,7 +112,7 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA notifKey := fmt.Sprintf("notifications:%s", u.Username) pipe.LPush(RedisCtx, notifKey, notifJSON) pipe.LTrim(RedisCtx, notifKey, 0, 199) - pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour) + pipe.Expire(RedisCtx, notifKey, time.Hour) } pipe.Exec(RedisCtx) //nolint @@ -130,7 +128,6 @@ func (d *Database) NotifyAllAdminCabine(commandID int, clientUsername, deliveryA log.Printf("📬 [ADMIN_NOTIF] Notif Redis (%d users) pour commande #%d", count, commandID) } -// NotifyAllAdminCabineAlert envoie une notification Redis à tous les admins/cabines lors d'une alerte func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alertMessage string) { var users []struct { Username string `gorm:"column:username"` @@ -156,7 +153,7 @@ func (d *Database) NotifyAllAdminCabineAlert(alertID int, livreurUsername, alert notifKey := fmt.Sprintf("notifications:%s", u.Username) pipe.LPush(RedisCtx, notifKey, notifJSON) pipe.LTrim(RedisCtx, notifKey, 0, 199) - pipe.Expire(RedisCtx, notifKey, 7*24*time.Hour) + pipe.Expire(RedisCtx, notifKey, time.Hour) } pipe.Exec(RedisCtx) //nolint diff --git a/backend/gestion/db/db_parrain.go b/backend/gestion/db/db_parrain.go index 98663bf9..65e32594 100644 --- a/backend/gestion/db/db_parrain.go +++ b/backend/gestion/db/db_parrain.go @@ -1,8 +1,11 @@ package db import ( + "database/sql" "fmt" "gestion/models" + + "gorm.io/gorm" ) func (d *Database) SetClientParrain(clientUsername, parrainUsername string) error { @@ -18,13 +21,44 @@ func (d *Database) SetClientParrain(clientUsername, parrainUsername string) erro return nil } +// SetClientParrainAndCredit assigne un parrain à un client et crédite le parrain +// dans une seule transaction, pour éviter un lien parrain enregistré sans le crédit associé. +func (d *Database) SetClientParrainAndCredit(clientUsername, parrainUsername string, creditAmount float64) error { + return d.GDB.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&models.Client{}). + Where("username = ? AND (parrain IS NULL OR parrain = '')", clientUsername). + Update("parrain", parrainUsername) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("client introuvable ou parrain déjà défini") + } + + if creditAmount > 0 { + result = tx.Model(&models.Client{}).Where("username = ?", parrainUsername). + Updates(map[string]any{"referral_balance": gorm.Expr("referral_balance + ?", creditAmount)}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return fmt.Errorf("parrain non trouvé") + } + } + return nil + }) +} + func (d *Database) GetClientParrain(clientUsername string) (string, error) { - var parrain string + var parrain sql.NullString err := d.GDB.Table("clients"). Select("parrain"). Where("username = ?", clientUsername). Scan(&parrain).Error - return parrain, err + if err != nil { + return "", err + } + return parrain.String, nil } func (d *Database) GetClientsByParrain(parrainUsername string) ([]models.Client, error) { diff --git a/backend/gestion/db/db_product.go b/backend/gestion/db/db_product.go index e8cb11b8..093ff385 100644 --- a/backend/gestion/db/db_product.go +++ b/backend/gestion/db/db_product.go @@ -73,14 +73,21 @@ func (d *Database) CreateProduct(product any) error { p.SetCreatedAt(result.CreatedAt) p.SetUpdatedAt(result.UpdatedAt) - for i, price := range p.GetPrices() { - err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`, - result.ID, price.Quantity, price.Price, price.ActivePrice).Error - if err != nil { - log.Printf("❌ [DB CreateProduct] Erreur insertion prix[%d]: %v", i, err) + if rawPrices := p.GetPrices(); len(rawPrices) > 0 { + priceRows := make([]models.ProductPrice, len(rawPrices)) + for i, price := range rawPrices { + priceRows[i] = models.ProductPrice{ + ProductID: result.ID, + Quantity: price.Quantity, + Price: price.Price, + ActivePrice: price.ActivePrice, + } + } + if err := d.GDB.Create(&priceRows).Error; err != nil { + log.Printf("❌ [DB CreateProduct] Erreur insertion prix batch: %v", err) return fmt.Errorf("erreur insertion prix: %v", err) } - log.Printf("✅ [DB CreateProduct] Prix[%d] inséré: quantity=%g, price=%.2f", i, price.Quantity, price.Price) + log.Printf("✅ [DB CreateProduct] %d prix insérés", len(priceRows)) } log.Printf("🎉 [DB CreateProduct] Produit créé avec succès! ID=%d", result.ID) @@ -136,6 +143,27 @@ func (d *Database) GetProductNamesByIDs(ids []int) (map[int]string, error) { return result, nil } +// GetProductCategoriesByIDs retourne un map id→category pour une liste d'IDs. +func (d *Database) GetProductCategoriesByIDs(ids []int) (map[int]string, error) { + result := make(map[int]string, len(ids)) + if len(ids) == 0 { + return result, nil + } + rows, err := d.GDB.Raw(`SELECT id, category FROM products WHERE id IN ?`, ids).Rows() + if err != nil { + return result, err + } + defer rows.Close() + for rows.Next() { + var id int + var category string + if err := rows.Scan(&id, &category); err == nil { + result[id] = category + } + } + return result, nil +} + func (d *Database) GetAllProducts() ([]models.Product, error) { log.Println("📦 [GetAllProducts] START") @@ -149,23 +177,22 @@ func (d *Database) GetAllProducts() ([]models.Product, error) { return nil, err } + productIDs := make([]int, len(products)) + for i, p := range products { + productIDs[i] = p.ID + } + allPrices := d.GetProductPricesBatch(productIDs) + allMedia := d.GetMediaBatch(productIDs) for i := range products { - prices, err := d.GetProductPrices(products[i].ID) - if err != nil { - log.Printf("⚠️ [GetAllProducts] Erreur loading prices for product %d: %v", products[i].ID, err) - products[i].Prices = []models.ProductPrice{} - } else { + if prices, ok := allPrices[products[i].ID]; ok { products[i].Prices = prices - log.Printf("✅ [GetAllProducts] Loaded %d prices for product %d", len(prices), products[i].ID) - } - - media, err := d.GetMediaByProductID(products[i].ID) - if err != nil { - log.Printf("⚠️ [GetAllProducts] Erreur loading media for product %d: %v", products[i].ID, err) - products[i].Media = []models.Media{} } else { + products[i].Prices = []models.ProductPrice{} + } + if media, ok := allMedia[products[i].ID]; ok { products[i].Media = media - log.Printf("✅ [GetAllProducts] Loaded %d media for product %d", len(media), products[i].ID) + } else { + products[i].Media = []models.Media{} } } @@ -188,14 +215,16 @@ func (d *Database) GetProductsByCategory(category string) ([]models.Product, err return nil, fmt.Errorf("erreur lors de la récupération des produits: %w", err) } + catProductIDs := make([]int, len(products)) + for i, p := range products { + catProductIDs[i] = p.ID + } + catPrices := d.GetProductPricesBatch(catProductIDs) for i := range products { - prices, err := d.GetProductPrices(products[i].ID) - if err != nil { - log.Printf("⚠️ [GetProductsByCategory] Erreur loading prices for product %d: %v", products[i].ID, err) - products[i].Prices = []models.ProductPrice{} - } else { + if prices, ok := catPrices[products[i].ID]; ok { products[i].Prices = prices - log.Printf("✅ [GetProductsByCategory] Loaded %d prices for product %d", len(prices), products[i].ID) + } else { + products[i].Prices = []models.ProductPrice{} } } @@ -215,9 +244,17 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni d.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID) - for _, price := range prices { - if err := d.GDB.Exec(`INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, ?, ?, ?)`, - productID, price.Quantity, price.Price, price.ActivePrice).Error; err != nil { + if len(prices) > 0 { + priceRows := make([]models.ProductPrice, len(prices)) + for i, price := range prices { + priceRows[i] = models.ProductPrice{ + ProductID: productID, + Quantity: price.Quantity, + Price: price.Price, + ActivePrice: price.ActivePrice, + } + } + if err := d.GDB.Create(&priceRows).Error; err != nil { return fmt.Errorf("erreur insertion prix: %w", err) } } diff --git a/backend/gestion/db/db_product_price.go b/backend/gestion/db/db_product_price.go index 5643184f..cd92fae8 100644 --- a/backend/gestion/db/db_product_price.go +++ b/backend/gestion/db/db_product_price.go @@ -13,6 +13,20 @@ func (d *Database) GetProductPrices(productID int) ([]models.ProductPrice, error return prices, nil } +// GetProductPricesBatch charge les prix de plusieurs produits en une seule requête. +func (d *Database) GetProductPricesBatch(productIDs []int) map[int][]models.ProductPrice { + result := make(map[int][]models.ProductPrice, len(productIDs)) + if len(productIDs) == 0 { + return result + } + var prices []models.ProductPrice + d.GDB.Where("product_id IN ?", productIDs).Order("product_id ASC, quantity ASC").Find(&prices) + for _, p := range prices { + result[p.ProductID] = append(result[p.ProductID], p) + } + return result +} + func (d *Database) AddActivePrice(priceID int) error { result := d.GDB.Model(&models.ProductPrice{}). Where("id = ?", priceID). diff --git a/backend/gestion/db/db_queue_auto_next.go b/backend/gestion/db/db_queue_auto_next.go index 6bc17060..3c25436d 100644 --- a/backend/gestion/db/db_queue_auto_next.go +++ b/backend/gestion/db/db_queue_auto_next.go @@ -189,7 +189,7 @@ func (d *Database) RemoveCommandFromAllQueues(commandID int, deliveryman string) } // 4. Vérifier toutes les autres queues de livreurs (au cas où) - keys, _ := Redis.Keys(RedisCtx, "queue:deliveryman:*").Result() + keys, _ := scanRedisKeys("queue:deliveryman:*") for _, key := range keys { if len(key) > 6 && key[len(key)-6:] == ":count" { continue diff --git a/backend/gestion/db/db_sanction_client.go b/backend/gestion/db/db_sanction_client.go index d7cd437d..90b5fd71 100644 --- a/backend/gestion/db/db_sanction_client.go +++ b/backend/gestion/db/db_sanction_client.go @@ -27,25 +27,6 @@ func (d *Database) GetClientCancellationsCount(username string) (int, error) { return result.Count, nil } -// IncrementClientCancellationsCount incrémente le compteur d'annulations -func (d *Database) IncrementClientCancellationsCount(username string) error { - result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Updates(map[string]any{ - "cancellations_count": gorm.Expr("COALESCE(cancellations_count, 0) + 1"), - }) - if result.Error != nil { - log.Printf("❌ [IncrementCancellations] Erreur: %v", result.Error) - return fmt.Errorf("erreur incrémentation: %w", result.Error) - } - if result.RowsAffected == 0 { - return fmt.Errorf("client non trouvé") - } - - cacheKey := fmt.Sprintf("client:%s", username) - Redis.Del(RedisCtx, cacheKey) - - return nil -} - // penaltyForCount retourne le montant du palier applicable pour un nombre d'annulations donné func penaltyForCount(count int, tiers []models.PenaltyTier) int { if len(tiers) == 0 { @@ -64,6 +45,16 @@ func penaltyForCount(count int, tiers []models.PenaltyTier) int { return sorted[len(sorted)-1].Amount } +// penaltyTiers charge le barème de pénalités configuré, avec repli sur le barème par défaut si les settings sont indisponibles +func (d *Database) penaltyTiers(logCtx string) []models.PenaltyTier { + settings, err := d.GetSettings() + if err != nil { + log.Printf("⚠️ [%s] Impossible de charger les settings, barème par défaut: %v", logCtx, err) + settings = DefaultSettings() + } + return settings.PenaltyTiers +} + // CalculateCancellationPenalty calcule la pénalité selon l'historique et le barème configuré func (d *Database) CalculateCancellationPenalty(username string) (int, error) { count, err := d.GetClientCancellationsCount(username) @@ -71,13 +62,7 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) { return 0, err } - settings, err := d.GetSettings() - if err != nil { - log.Printf("⚠️ [CalculatePenalty] Impossible de charger les settings, barème par défaut: %v", err) - settings = DefaultSettings() - } - - penalty := penaltyForCount(count, settings.PenaltyTiers) + penalty := penaltyForCount(count, d.penaltyTiers("CalculatePenalty")) log.Printf("💰 [CalculatePenalty] Client %s - Annulations: %d → Pénalité: %d points", username, count, penalty) @@ -85,30 +70,48 @@ func (d *Database) CalculateCancellationPenalty(username string) (int, error) { return penalty, nil } -// ApplyCancellationPenalty applique une pénalité et incrémente le compteur d'annulations +// ApplyCancellationPenalty applique une pénalité (cumulative) et incrémente le compteur d'annulations. +// Verrouillée via FOR UPDATE pour éviter qu'un appel concurrent (même client, deux livraisons en parallèle) +// calcule la pénalité sur un compteur pas encore à jour, et l'amende s'additionne au lieu d'écraser +// le solde existant (cohérent avec CancelCommandAtomic pour l'annulation côté client). func (d *Database) ApplyCancellationPenalty(username string) (int, error) { - penalty, err := d.CalculateCancellationPenalty(username) + tiers := d.penaltyTiers("ApplyCancellationPenalty") + + var penalty int + + err := d.GDB.Transaction(func(tx *gorm.DB) error { + var count int + if err := tx.Raw(` + SELECT COALESCE(cancellations_count, 0) FROM clients + WHERE username = ? FOR UPDATE`, username).Scan(&count).Error; err != nil { + return fmt.Errorf("erreur récupération compteur: %w", err) + } + + penalty = penaltyForCount(count, tiers) + + log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty) + + result := tx.Exec(` + UPDATE clients + SET cancellations_count = COALESCE(cancellations_count, 0) + 1, + amende = amende + ?, + updated_at = CURRENT_TIMESTAMP + WHERE username = ?`, penalty, username) + if result.Error != nil { + log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error) + return fmt.Errorf("erreur application pénalité: %w", result.Error) + } + if result.RowsAffected == 0 { + return fmt.Errorf("client non trouvé") + } + + log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username) + return nil + }) if err != nil { return 0, err } - log.Printf("⚠️ [ApplyCancellationPenalty] Client %s - Pénalité calculée: %d points", username, penalty) - - if err := d.IncrementClientCancellationsCount(username); err != nil { - return 0, err - } - - result := d.GDB.Model(&models.Client{}).Where("username = ?", username).Update("amende", float64(penalty)) - if result.Error != nil { - log.Printf("❌ [ApplyCancellationPenalty] Erreur UPDATE: %v", result.Error) - return 0, fmt.Errorf("erreur application pénalité: %w", result.Error) - } - if result.RowsAffected == 0 { - return 0, fmt.Errorf("client non trouvé") - } - - log.Printf("✅ [ApplyCancellationPenalty] Amende %d appliquée à %s", penalty, username) - cacheKey := fmt.Sprintf("client:%s", username) Redis.Del(RedisCtx, cacheKey) diff --git a/backend/gestion/db/db_settings.go b/backend/gestion/db/db_settings.go index 996bf74e..1affe22a 100644 --- a/backend/gestion/db/db_settings.go +++ b/backend/gestion/db/db_settings.go @@ -72,6 +72,18 @@ func DefaultSettings() models.AppSettings { Mode: "single", CategoryRoutes: []models.CategoryRoute{}, }, + AdminColorPrimary: "#7c3aed", + AdminColorSecondary: "#000000", + AdminColorSuccess: "#4ade80", + AdminColorDanger: "#ef4444", + AdminColorWarning: "#f59e0b", + ClientColorPrimary: "#7c3aed", + ClientColorSecondary: "#000000", + ClientColorSuccess: "#4ade80", + ClientColorDanger: "#ef4444", + ClientColorWarning: "#f59e0b", + ClientTitleGradientFrom: "#a78bfa", + ClientTitleGradientTo: "#22d3ee", DeliverySchedule: DefaultDeliverySchedule(), PostalZones: []models.PostalZone{ {Name: "Zone 30€", MinAmount: 30, Codes: []string{"44000", "44100", "44200", "44300"}}, @@ -163,6 +175,30 @@ func (d *Database) GetSettings() (models.AppSettings, error) { settings.Telegram2FAEnabled = row.Value == "true" case "shop_name": settings.ShopName = row.Value + case "admin_color_primary": + settings.AdminColorPrimary = row.Value + case "admin_color_secondary": + settings.AdminColorSecondary = row.Value + case "admin_color_success": + settings.AdminColorSuccess = row.Value + case "admin_color_danger": + settings.AdminColorDanger = row.Value + case "admin_color_warning": + settings.AdminColorWarning = row.Value + case "client_color_primary": + settings.ClientColorPrimary = row.Value + case "client_color_secondary": + settings.ClientColorSecondary = row.Value + case "client_color_success": + settings.ClientColorSuccess = row.Value + case "client_color_danger": + settings.ClientColorDanger = row.Value + case "client_color_warning": + settings.ClientColorWarning = row.Value + case "client_title_gradient_from": + settings.ClientTitleGradientFrom = row.Value + case "client_title_gradient_to": + settings.ClientTitleGradientTo = row.Value } } return settings, nil @@ -253,6 +289,18 @@ func (d *Database) UpdateSettings(s models.AppSettings) error { {"delivery_mode", string(deliveryModeJSON)}, {"shop_name", s.ShopName}, {"contact_telegram", s.ContactTelegram}, + {"admin_color_primary", s.AdminColorPrimary}, + {"admin_color_secondary", s.AdminColorSecondary}, + {"admin_color_success", s.AdminColorSuccess}, + {"admin_color_danger", s.AdminColorDanger}, + {"admin_color_warning", s.AdminColorWarning}, + {"client_color_primary", s.ClientColorPrimary}, + {"client_color_secondary", s.ClientColorSecondary}, + {"client_color_success", s.ClientColorSuccess}, + {"client_color_danger", s.ClientColorDanger}, + {"client_color_warning", s.ClientColorWarning}, + {"client_title_gradient_from", s.ClientTitleGradientFrom}, + {"client_title_gradient_to", s.ClientTitleGradientTo}, } upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) diff --git a/backend/gestion/db/db_stat.go b/backend/gestion/db/db_stat.go new file mode 100644 index 00000000..9c283d8b --- /dev/null +++ b/backend/gestion/db/db_stat.go @@ -0,0 +1,449 @@ +package db + +import ( + "gestion/models" + "time" +) + +// ── Reset des sections de stats ───────────────────────────────────────────── + +// ResetAdminStat enregistre (ou met à jour) la date de reset pour une section. +func (d *Database) ResetAdminStat(section string) error { + now := time.Now().UTC().Format(time.RFC3339) + upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + return d.GDB.Exec(upsert, section, now).Error +} + +// ReadResetAt lit la date de reset stockée pour une clé donnée (zero value si absente). +func (d *Database) ReadResetAt(key string) time.Time { + var row struct { + Value string + } + err := d.GDB.Table("app_settings"). + Select("value"). + Where("key = ?", key). + Scan(&row).Error + if err != nil { + return time.Time{} + } + if row.Value != "" { + if t, err := time.Parse(time.RFC3339, row.Value); err == nil { + return t + } + } + return time.Time{} +} + +// ── Construction des clauses WHERE (filtrage par reset) ──────────────────── + +// statusFilterClause construit " [AND created_at >= ?]" et renvoie +// la clause ainsi que les arguments à binder, dans l'ordre. +func statusFilterClause(baseStatus string, resetAt time.Time) (string, []interface{}) { + if !resetAt.IsZero() { + return baseStatus + " AND created_at >= ?", []interface{}{resetAt.Format(time.RFC3339)} + } + return baseStatus, nil +} + +// AdminStatsFilters regroupe les dates de reset pour chaque section, lues une +// seule fois puis transmises aux différentes requêtes. +type AdminStatsFilters struct { + ResetCommandes time.Time + ResetRevenus time.Time + ResetProduits time.Time + ResetHeures time.Time + ResetJours time.Time + ResetDoses time.Time +} + +// LoadAdminStatsFilters lit toutes les dates de reset en une seule requête. +func (d *Database) LoadAdminStatsFilters() AdminStatsFilters { + keys := []string{ + "stats_reset_commandes_at", + "stats_reset_revenus_at", + "stats_reset_produits_at", + "stats_reset_heures_at", + "stats_reset_jours_at", + "stats_reset_doses_at", + } + var rows []struct { + Key string `gorm:"column:key"` + Value string `gorm:"column:value"` + } + d.GDB.Table("app_settings").Select("key, value").Where("key IN ?", keys).Scan(&rows) + + m := make(map[string]time.Time, len(keys)) + for _, r := range rows { + if t, err := time.Parse(time.RFC3339, r.Value); err == nil { + m[r.Key] = t + } + } + return AdminStatsFilters{ + ResetCommandes: m["stats_reset_commandes_at"], + ResetRevenus: m["stats_reset_revenus_at"], + ResetProduits: m["stats_reset_produits_at"], + ResetHeures: m["stats_reset_heures_at"], + ResetJours: m["stats_reset_jours_at"], + ResetDoses: m["stats_reset_doses_at"], + } +} + +// ── Commandes par jour de la semaine (non annulées) ───────────────────────── + +func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt time.Time) error { + where, args := statusFilterClause("status != 'cancelled'", resetAt) + query := ` + SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count + FROM commandes + WHERE ` + where + ` + GROUP BY dow + ORDER BY dow + ` + return d.GDB.Raw(query, args...).Scan(wdRows).Error +} + +// ── Commandes par jour sur 30 jours ────────────────────────────────────────── + +func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error { + where, args := statusFilterClause("status != 'cancelled'", resetAt) + query := ` + SELECT DATE(created_at) AS day, COUNT(*) AS count + FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' + AND ` + where + ` + GROUP BY DATE(created_at) + ORDER BY day + ` + return d.GDB.Raw(query, args...).Scan(dayRows).Error +} + +// ── Revenus par jour sur 30 jours (commandes approuvées) ───────────────────── + +func (d *Database) RevenueByDayLast30(dayRevRows *[]models.DayRevenueRow, resetAt time.Time) error { + where, args := statusFilterClause("status = 'approved'", resetAt) + query := ` + SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' + AND ` + where + ` + GROUP BY DATE(created_at) + ORDER BY day + ` + return d.GDB.Raw(query, args...).Scan(dayRevRows).Error +} + +// ── Commandes par jour sur un mois calendaire complet ──────────────────────── + +type DailyMonthStatRow struct { + Day time.Time + Count int + Revenue float64 + Quantity float64 +} + +func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetAt time.Time) error { + start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) + end := start.AddDate(0, 1, 0) + + where, whereArgs := statusFilterClause("status != 'cancelled'", resetAt) + + query := ` + SELECT + d.day, + COALESCE(d.count, 0) AS count, + COALESCE(rv.revenue, 0) AS revenue, + COALESCE(qt.quantity, 0) AS quantity + FROM ( + SELECT DATE(created_at) AS day, COUNT(*) AS count + FROM commandes + WHERE created_at >= ? AND created_at < ? + AND ` + where + ` + GROUP BY DATE(created_at) + ) d + LEFT JOIN ( + SELECT DATE(created_at) AS day, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE created_at >= ? AND created_at < ? + AND status = 'approved' + GROUP BY DATE(created_at) + ) rv ON rv.day = d.day + LEFT JOIN ( + SELECT DATE(c.created_at) AS day, SUM(ci.quantite) AS quantity + FROM commandes c + JOIN command_items ci ON ci.command_id = c.id + WHERE c.created_at >= ? AND c.created_at < ? + AND c.status != 'cancelled' + GROUP BY DATE(c.created_at) + ) qt ON qt.day = d.day + ORDER BY d.day + ` + + // Ordre des "?" dans la requête : (start, end, [reset]) pour le bloc "d", + // puis (start, end) pour "rv", puis (start, end) pour "qt". + args := []interface{}{start, end} + args = append(args, whereArgs...) + args = append(args, start, end) + args = append(args, start, end) + + return d.GDB.Raw(query, args...).Scan(rows).Error +} + +// OrdersAndRevenueByHour renvoie, par heure, le nombre de commandes non annulées +// (volume d'activité) et le revenu confirmé (commandes approuvées uniquement — +// cohérent avec TotalRevenue/RevenueByDayLast30, pour ne pas compter comme +// "revenu" une commande encore en cours qui pourrait être annulée). +func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt time.Time) error { + where, args := statusFilterClause("status != 'cancelled'", resetAt) + query := ` + SELECT + EXTRACT(HOUR FROM created_at)::int AS hour, + COUNT(*) AS count, + COALESCE(SUM(CASE WHEN status = 'approved' THEN total_prix - COALESCE(referral_used, 0) ELSE 0 END), 0) AS revenue + FROM commandes + WHERE ` + where + ` + GROUP BY hour + ORDER BY hour + ` + return d.GDB.Raw(query, args...).Scan(hourRows).Error +} + +// ── Top produits (quantité vendue) ─────────────────────────────────────────── + +// TopProducts renvoie les produits les plus commandés. La quantité/le nombre de +// commandes reflètent l'activité (non annulées), le revenu ne compte que les +// commandes approuvées (revenu confirmé, cohérent avec le résumé global). +func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time, limit int) error { + where, args := statusFilterClause("c.status != 'cancelled'", resetAt) + args = append(args, limit) + query := ` + SELECT + ci.product_id, + ci.produit AS name, + SUM(ci.quantite) AS total_quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(CASE WHEN c.status = 'approved' + THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0) + ELSE 0 END) AS revenue, + COALESCE(p.category, '') AS category, + COALESCE(cat.color, '#7c3aed') AS category_color + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + LEFT JOIN products p ON p.id = ci.product_id + LEFT JOIN categories cat ON cat.name = p.category + WHERE ` + where + ` + GROUP BY ci.product_id, ci.produit, p.category, cat.color + ORDER BY total_quantity DESC + LIMIT ? + ` + return d.GDB.Raw(query, args...).Scan(prodRows).Error +} + +// ── Répartition des doses/quantités par produit ────────────────────────────── + +// QuantityBreakdown : quantité/nombre de commandes reflètent l'activité (non +// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé). +func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, resetAt time.Time) error { + where, args := statusFilterClause("c.status != 'cancelled'", resetAt) + query := ` + SELECT + ci.product_id, + ci.produit AS product_name, + ci.quantite AS quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(ci.quantite) AS total_sold, + SUM(CASE WHEN c.status = 'approved' + THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0) + ELSE 0 END) AS revenue, + COALESCE(cat.color, '#7c3aed') AS category_color + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + LEFT JOIN products p ON p.id = ci.product_id + LEFT JOIN categories cat ON cat.name = p.category + WHERE ` + where + ` + GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color + ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC + ` + return d.GDB.Raw(query, args...).Scan(qtyRows).Error +} + +// ── Détail du jour (catégorie → produits) ──────────────────────────────────── + +// DailyProductDetail : quantité/nombre de commandes reflètent l'activité (non +// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé). +func (d *Database) DailyProductDetail(dailyRows *[]models.DailyProductRow) error { + query := ` + SELECT + ci.product_id, + ci.produit AS product_name, + COALESCE(p.category, 'Sans catégorie') AS category, + COALESCE(cat.color, '#7c3aed') AS category_color, + SUM(ci.quantite) AS total_quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(CASE WHEN c.status = 'approved' + THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0) + ELSE 0 END) AS revenue + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + LEFT JOIN products p ON p.id = ci.product_id + LEFT JOIN categories cat ON cat.name = p.category + WHERE DATE(c.created_at) = CURRENT_DATE + AND c.status != 'cancelled' + GROUP BY ci.product_id, ci.produit, p.category, cat.color + ORDER BY p.category, SUM(ci.quantite) DESC + ` + return d.GDB.Raw(query).Scan(dailyRows).Error +} + +func (d *Database) DailyProductDetailForDate(dailyRows *[]models.DailyProductRow, date time.Time) error { + start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + end := start.AddDate(0, 0, 1) + + query := ` + SELECT + ci.product_id, + ci.produit AS product_name, + COALESCE(p.category, 'Sans catégorie') AS category, + COALESCE(cat.color, '#7c3aed') AS category_color, + SUM(ci.quantite) AS total_quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(CASE WHEN c.status = 'approved' + THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0) + ELSE 0 END) AS revenue + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + LEFT JOIN products p ON p.id = ci.product_id + LEFT JOIN categories cat ON cat.name = p.category + WHERE c.created_at >= ? AND c.created_at < ? + AND c.status != 'cancelled' + GROUP BY ci.product_id, ci.produit, p.category, cat.color + ORDER BY p.category, SUM(ci.quantite) DESC + ` + return d.GDB.Raw(query, start, end).Scan(dailyRows).Error +} + +// DailyOrdersCountForDate renvoie le nombre de commandes (non annulées) pour +// une date précise. +func (d *Database) DailyOrdersCountForDate(date time.Time) (int64, error) { + start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) + end := start.AddDate(0, 0, 1) + + var count int64 + err := d.GDB.Raw(` + SELECT COUNT(DISTINCT id) FROM commandes + WHERE created_at >= ? AND created_at < ? AND status != 'cancelled' + `, start, end).Scan(&count).Error + return count, err +} + +// DailyOrdersCount renvoie le nombre de commandes (non annulées) du jour. +func (d *Database) DailyOrdersCount() (int64, error) { + var count int64 + err := d.GDB.Raw(` + SELECT COUNT(DISTINCT id) FROM commandes + WHERE DATE(created_at) = CURRENT_DATE AND status != 'cancelled' + `).Scan(&count).Error + return count, err +} + +// ── Résumé global ──────────────────────────────────────────────────────────── + +// TotalOrders renvoie le nombre total de commandes filtré par le reset "commandes". +func (d *Database) TotalOrders(resetAt time.Time) (int64, error) { + where, args := statusFilterClause("status != 'cancelled'", resetAt) + var total int64 + err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error + return total, err +} + +// TotalRevenue renvoie le revenu total (commandes approuvées) filtré par le reset "revenus". +func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) { + where, args := statusFilterClause("status = 'approved'", resetAt) + var total float64 + err := d.GDB.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE `+where, args...). + Scan(&total).Error + return total, err +} + +// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours. +func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) { + where, args := statusFilterClause("status != 'cancelled'", resetAt) + var activeDays int64 + query := ` + SELECT COUNT(DISTINCT DATE(created_at)) + FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where + err := d.GDB.Raw(query, args...).Scan(&activeDays).Error + return activeDays, err +} + +// OrdersCountLast30 renvoie le nombre de commandes sur les 30 derniers jours. +func (d *Database) OrdersCountLast30(resetAt time.Time) (int64, error) { + where, args := statusFilterClause("status != 'cancelled'", resetAt) + var count int64 + query := ` + SELECT COUNT(*) FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where + err := d.GDB.Raw(query, args...).Scan(&count).Error + return count, err +} + +func (d *Database) GetMyDeliveryStatsPerDay(statsRows *[]models.DayRowWithResult, username string) error { + query := ` + SELECT DATE(updated_at) AS day, + COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE livreur_assign = ? + AND status IN ('livre', 'approved') + AND updated_at >= NOW() - INTERVAL '30 days' + GROUP BY DATE(updated_at) + ORDER BY day + ` + return d.GDB.Raw(query, username).Scan(statsRows).Error +} + +func (d *Database) GetMyDeliveryStatsPerWeek(statsRow *[]models.WeekRow, username string) error { + query := ` + SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num, + EXTRACT(YEAR FROM updated_at)::int AS year, + COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE livreur_assign = ? + AND status IN ('livre', 'approved') + AND updated_at >= NOW() - INTERVAL '12 weeks' + GROUP BY week_num, year + ORDER BY year, week_num + ` + return d.GDB.Raw(query, username).Scan(statsRow).Error +} + +func (d *Database) GetMyDeliveryStatsPerMonth(statsRow *[]models.MonthRow, username string) error { + query := ` + SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num, + EXTRACT(YEAR FROM updated_at)::int AS year, + COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE livreur_assign = ? + AND status IN ('livre', 'approved') + AND updated_at >= NOW() - INTERVAL '12 months' + GROUP BY month_num, year + ORDER BY year, month_num + ` + return d.GDB.Raw(query, username).Scan(statsRow).Error +} + +func (d *Database) GetMyDeliveryStatsToday(statsRow *models.TodayRow, username string) error { + query := ` + SELECT COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE livreur_assign = ? + AND status IN ('livre', 'approved') + AND DATE(updated_at) = CURRENT_DATE + ` + return d.GDB.Raw(query, username).Scan(statsRow).Error +} diff --git a/backend/gestion/db/db_telegram.go b/backend/gestion/db/db_telegram.go index c521def1..85255871 100644 --- a/backend/gestion/db/db_telegram.go +++ b/backend/gestion/db/db_telegram.go @@ -43,7 +43,7 @@ func GenerateLinkToken(username, role string) (string, error) { key := fmt.Sprintf("telegram:link:%s", token) if err := Redis.Set(RedisCtx, key, val, linkTokenTTL).Err(); err != nil { - return "", fmt.Errorf("Redis SET: %w", err) + return "", fmt.Errorf("redis set: %w", err) } return token, nil } diff --git a/backend/gestion/db/db_users.go b/backend/gestion/db/db_users.go index 3675bad8..bbd87475 100644 --- a/backend/gestion/db/db_users.go +++ b/backend/gestion/db/db_users.go @@ -15,7 +15,7 @@ func (d *Database) CreateUser(user *models.User) error { func (d *Database) GetAllUsers() ([]*models.User, error) { var users []*models.User - if err := d.GDB.Order("created_at DESC").Find(&users).Error; err != nil { + if err := d.GDB.Order("created_at DESC").Limit(500).Find(&users).Error; err != nil { return nil, fmt.Errorf("erreur lors de la récupération des utilisateurs: %w", err) } return users, nil @@ -23,7 +23,7 @@ func (d *Database) GetAllUsers() ([]*models.User, error) { func (d *Database) GetAllDeliveryMen() ([]*models.User, error) { var users []*models.User - if err := d.GDB.Where("role = ?", "livreur").Find(&users).Error; err != nil { + if err := d.GDB.Where("role = ?", "livreur").Limit(100).Find(&users).Error; err != nil { return nil, fmt.Errorf("erreur lors de la récupération des livreurs: %w", err) } return users, nil diff --git a/backend/gestion/db/redis_deliveryman_assignment.go b/backend/gestion/db/redis_deliveryman_assignment.go index 9c513522..82e4c91e 100644 --- a/backend/gestion/db/redis_deliveryman_assignment.go +++ b/backend/gestion/db/redis_deliveryman_assignment.go @@ -10,7 +10,7 @@ import ( // FindLeastLoadedDeliveryman trouve le livreur avec le moins de commandes ET qui peut accepter func (d *Database) FindLeastLoadedDeliveryman() (string, error) { - keys, err := Redis.Keys(RedisCtx, "delivery:status:*").Result() + keys, err := scanRedisKeys("delivery:status:*") if err != nil || len(keys) == 0 { return "", fmt.Errorf("aucun livreur trouvé") } diff --git a/backend/gestion/db/redis_eta_management.go b/backend/gestion/db/redis_eta_management.go index fc76f158..a84c25f7 100644 --- a/backend/gestion/db/redis_eta_management.go +++ b/backend/gestion/db/redis_eta_management.go @@ -165,6 +165,7 @@ func (d *Database) SetCommandETAWithDetails(commandID, totalETA, queuePosition i eta := map[string]any{ "command_id": commandID, "total_eta_minutes": totalETA, + "eta_minutes": totalETA, "queue_position": queuePosition, "updated_at": now.Unix(), "arrival_time": arrivalTime.Unix(), diff --git a/backend/gestion/db/redis_queue_clean_up.go b/backend/gestion/db/redis_queue_clean_up.go index 36904a35..7d7eb702 100644 --- a/backend/gestion/db/redis_queue_clean_up.go +++ b/backend/gestion/db/redis_queue_clean_up.go @@ -9,7 +9,6 @@ import ( "time" ) -// CleanupInvalidQueueCommands supprime toutes les commandes avec des données manquantes func (d *Database) CleanupInvalidQueueCommands() (int, error) { log.Println("🧹 [CLEANUP] Démarrage du nettoyage des commandes invalides...") @@ -82,7 +81,6 @@ func (d *Database) CleanupInvalidQueueCommands() (int, error) { return removedCount, nil } -// removeInvalidCommand supprime une commande invalide de toutes les queues func (d *Database) removeInvalidCommand(key string, commandID int, reason string) { commandIDStr := fmt.Sprintf("%d", commandID) diff --git a/backend/gestion/db/redis_queue_info.go b/backend/gestion/db/redis_queue_info.go index 7a518981..dbca88a4 100644 --- a/backend/gestion/db/redis_queue_info.go +++ b/backend/gestion/db/redis_queue_info.go @@ -91,7 +91,6 @@ func (d *Database) GetAllQueuesOverview() (map[string]any, error) { return overview, nil } -// GetQueueStats - Statistiques détaillées func (d *Database) GetQueueStats() (map[string]any, error) { normalCount, _ := Redis.ZCard(RedisCtx, "queue:pending:sorted").Result() priorityCount, _ := Redis.ZCard(RedisCtx, "queue:priority:sorted").Result() diff --git a/backend/gestion/db/redis_queue_management.go b/backend/gestion/db/redis_queue_management.go index 4d46d060..679c228a 100644 --- a/backend/gestion/db/redis_queue_management.go +++ b/backend/gestion/db/redis_queue_management.go @@ -142,7 +142,6 @@ func (d *Database) AddToGeneralQueue(queueItem models.CommandQueue) error { return nil } -// RemoveCommandFromQueue - VERSION AMÉLIORÉE avec auto-update du statut func (d *Database) RemoveCommandFromQueue(commandID int) error { key := fmt.Sprintf("queue:pending:%d", commandID) commandIDStr := strconv.Itoa(commandID) @@ -321,7 +320,6 @@ func (d *Database) iterDeliveryStatuses(fn func(models.DeliveryPersonStatus)) er return nil } -// scanRedisKeys remplace KEYS * par SCAN pour ne pas bloquer Redis. func scanRedisKeys(pattern string) ([]string, error) { var all []string cursor := uint64(0) diff --git a/backend/gestion/go.mod b/backend/gestion/go.mod index 79eef9b8..d0006bff 100644 --- a/backend/gestion/go.mod +++ b/backend/gestion/go.mod @@ -19,6 +19,24 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.25 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.24 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect + github.com/aws/smithy-go v1.27.1 // indirect github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/backend/gestion/go.sum b/backend/gestion/go.sum index 5c211e60..e25cce69 100644 --- a/backend/gestion/go.sum +++ b/backend/gestion/go.sum @@ -1,3 +1,39 @@ +github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= +github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls= +github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM= +github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I= +github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE= +github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0 h1:3nXpRcFwRCW8n7HgO2QGy0Dc20eQNfBuUemGQhpF8m8= +github.com/aws/aws-sdk-go-v2/service/signin v1.2.0/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo= +github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI= +github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc= +github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8= +github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= diff --git a/backend/gestion/handlers/alert.go b/backend/gestion/handlers/alert.go index b2fd2669..655e59e4 100644 --- a/backend/gestion/handlers/alert.go +++ b/backend/gestion/handlers/alert.go @@ -27,7 +27,6 @@ func AlertPolice(c *gin.Context) { var req struct { Message string `json:"message"` } - // message optionnel — on ignore l'erreur de bind _ = c.ShouldBindJSON(&req) usernameStr := username.(string) @@ -61,6 +60,25 @@ func DeleteAlert(c *gin.Context) { c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"}) return } + + // Un livreur ne peut supprimer que ses propres alertes — admin garde l'accès complet. + if userRole == "livreur" { + username, exists := c.Get("username") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + alert, err := database.GetAlertPolicy(alertID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Alerte non trouvée"}) + return + } + if alert.Username != username.(string) { + c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"}) + return + } + } + if err = database.DeleteAlertPolicy(alertID); err != nil { utils.ServerErr(c, "Impossible de supprimer l'alerte", err) return @@ -92,6 +110,15 @@ func GetAlert(c *gin.Context) { return } + // Un livreur ne peut consulter que ses propres alertes — admin/cabine gardent l'accès complet pour le dispatch + if userRole == "livreur" { + username, exists := c.Get("username") + if !exists || alert.Username != username.(string) { + c.JSON(http.StatusForbidden, gin.H{"error": "Cette alerte ne vous appartient pas"}) + return + } + } + c.JSON(http.StatusOK, gin.H{ "success": true, "alert": alert, diff --git a/backend/gestion/handlers/auth.go b/backend/gestion/handlers/auth.go index 03eee1fc..55833fa7 100644 --- a/backend/gestion/handlers/auth.go +++ b/backend/gestion/handlers/auth.go @@ -55,13 +55,13 @@ func generateAdminToken(user *models.User) (string, error) { claims := models.AdminClaims{ UserID: user.ID, Username: user.Username, - Role: user.Role, // ← "admin" ou "cabine" ou "livreur" + Role: user.Role, SessionID: sessionID, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(time.Now().Add(adminTokenDuration)), IssuedAt: jwt.NewNumericDate(time.Now()), NotBefore: jwt.NewNumericDate(time.Now()), - Issuer: "api-admin", // Même issuer pour tous les admins + Issuer: "api-admin", Subject: strconv.Itoa(user.ID), }, } @@ -73,112 +73,6 @@ func generateAdminToken(user *models.User) (string, error) { return tokenString, nil } -// RegisterClient crée un nouveau compte client -func RegisterClient(c *gin.Context) { - var req models.RegisterClientRequest - if err := c.ShouldBindJSON(&req); err != nil { - log.Printf("❌ [REGISTER_CLIENT] Erreur binding: %v", err) - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Données invalides", - }) - return - } - - // Sanitize text inputs - req.Username = utils.StripHTML(req.Username) - req.Nom = utils.StripHTML(req.Nom) - req.Prenom = utils.StripHTML(req.Prenom) - - // Validation téléphone - if !utils.ValidatePhoneNumber(req.Telephone) { - log.Printf("❌ [REGISTER_CLIENT] Téléphone invalide: %s", req.Telephone) - c.JSON(http.StatusBadRequest, gin.H{ - "error": "Numéro de téléphone invalide", - }) - return - } - - normalizedPhone := utils.NormalizePhoneNumber(req.Telephone) - database := c.MustGet("database").(*db.Database) - - // Vérifier username unique - if existingClient, _ := database.GetClientByUsername(req.Username); existingClient != nil { - log.Printf("❌ [REGISTER_CLIENT] Username déjà utilisé: %s", req.Username) - c.JSON(http.StatusConflict, gin.H{"error": "Nom d'utilisateur déjà utilisé"}) - return - } - - // Vérifier téléphone unique - if existingClient, _ := database.GetClientByTelephone(normalizedPhone); existingClient != nil { - log.Printf("❌ [REGISTER_CLIENT] Téléphone déjà utilisé: %s", normalizedPhone) - c.JSON(http.StatusConflict, gin.H{"error": "Ce numéro de téléphone est déjà utilisé"}) - return - } - - // Hasher le mot de passe - hashed, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - log.Printf("❌ [REGISTER_CLIENT] Erreur bcrypt: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur traitement mot de passe"}) - return - } - - // Créer le client - client := &models.Client{ - Username: req.Username, - Password: string(hashed), - Nom: strings.TrimSpace(req.Nom), - Prenom: strings.TrimSpace(req.Prenom), - Telephone: normalizedPhone, - CreatedAt: time.Now(), - } - - if err := database.CreateClient(client); err != nil { - log.Printf("❌ [REGISTER_CLIENT] Erreur création: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création client"}) - return - } - - // Générer le token - token, err := generateClientToken(client) - if err != nil { - log.Printf("❌ [REGISTER_CLIENT] Erreur génération token: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur génération token"}) - return - } - - // Sauvegarder le token - expiresAt := time.Now().Add(clientTokenDuration) - if err := database.SaveToken(client.ID, "client", token, expiresAt); err != nil { - log.Printf("❌ [REGISTER_CLIENT] Erreur SaveToken: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement token"}) - return - } - - // Créer la session Redis - sessionID := uuid.New().String() - if err := database.CreateClientSession(client.ID, client.Username, sessionID); err != nil { - log.Printf("⚠️ [REGISTER_CLIENT] Erreur session Redis: %v", err) - } - - client.Password = "" - - c.JSON(http.StatusCreated, models.LoginResponse{ - AccessToken: token, - TokenType: "Bearer", - ExpiresIn: int(clientTokenDuration.Seconds()), - User: gin.H{ - "id": client.ID, - "username": client.Username, - "nom": client.Nom, - "prenom": client.Prenom, - "telephone": client.Telephone, - "role": "client", - "session_id": sessionID, - }, - }) -} - // AdminCreateClient crée un client depuis l'interface admin (sans session ni token) func AdminCreateClient(c *gin.Context) { if userRole := c.GetString("role"); userRole != "admin" { @@ -561,6 +455,12 @@ func LoginAdmin(c *gin.Context) { return } + if user.Role == "livreur" { + if err := database.RecordLivreurLogin(user.Username); err != nil { + log.Printf("⚠️ [LOGIN_ADMIN] Erreur enregistrement historique connexion livreur: %v", err) + } + } + token, _ := generateAdminToken(user) expiresAt := time.Now().Add(adminTokenDuration) @@ -602,62 +502,6 @@ func LogoutAdmin(c *gin.Context) { // HELPERS // ============================================ -// GetCurrentClient récupère le client actuel -// GET /api/v1/profile/client -func GetCurrentClient(c *gin.Context) { - clientID := c.GetInt("client_id") - database := c.MustGet("database").(*db.Database) - - client, err := database.GetClientByID(clientID) - if err != nil { - log.Printf("❌ [GET_CURRENT_CLIENT] Client non trouvé: ID=%d", clientID) - c.JSON(http.StatusNotFound, gin.H{"error": "Client non trouvé"}) - return - } - - client.Password = "" - - log.Printf("✅ [GET_CURRENT_CLIENT] Client récupéré: %s", client.Username) - - c.JSON(http.StatusOK, gin.H{ - "client": gin.H{ - "id": client.ID, - "username": client.Username, - "nom": client.Nom, - "prenom": client.Prenom, - "telephone": client.Telephone, - "command": client.Command, - "amende": client.Amende, - "points_extra": client.PointsExtra, - }, - }) -} - -// GetCurrentAdmin récupère l'admin/user actuel -// GET /api/v1/profile/admin -func GetCurrentAdmin(c *gin.Context) { - userID := c.GetInt("user_id") - database := c.MustGet("database").(*db.Database) - - user, err := database.GetUserByID(userID) - if err != nil { - log.Printf("❌ [GET_CURRENT_ADMIN] User non trouvé: ID=%d", userID) - c.JSON(http.StatusNotFound, gin.H{"error": "Utilisateur non trouvé"}) - return - } - - user.Password = "" - - log.Printf("✅ [GET_CURRENT_ADMIN] User récupéré: %s", user.Username) - - c.JSON(http.StatusOK, gin.H{ - "user": models.ProfileResponse{ - Username: user.Username, - Role: user.Role, - }, - }) -} - // GetAllUsers récupère tous les utilisateurs (Admin only) func GetAllUsers(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -855,3 +699,9 @@ func CreateUser(c *gin.Context) { log.Printf("✅ [CREATE_USER] Utilisateur %s (%s) créé", user.Username, user.Role) c.JSON(http.StatusCreated, gin.H{"message": "Utilisateur créé"}) } + +func Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + }) +} diff --git a/backend/gestion/handlers/cancel_command.go b/backend/gestion/handlers/cancel_command.go index ee437f8f..a00d275c 100644 --- a/backend/gestion/handlers/cancel_command.go +++ b/backend/gestion/handlers/cancel_command.go @@ -195,7 +195,6 @@ func CancelCommandByClient(c *gin.Context) { return } - // ✅ AUTRES ERREURS switch err.Error() { case "commande non trouvée": c.JSON(http.StatusNotFound, gin.H{"error": "Commande non trouvée"}) @@ -312,7 +311,6 @@ func GetAllCancelledOrders(c *gin.Context) { return } - // ✅ ENRICHIR les données (sans exposer d'infos sensibles inutiles) var enrichedOrders []map[string]any for _, order := range cancelledOrders { orderID, _ := strconv.Atoi(fmt.Sprintf("%v", order["id"])) diff --git a/backend/gestion/handlers/categories.go b/backend/gestion/handlers/categories.go index 7f3c8801..ff92f9c2 100644 --- a/backend/gestion/handlers/categories.go +++ b/backend/gestion/handlers/categories.go @@ -109,6 +109,26 @@ func UpdateCategory(c *gin.Context) { }) } +func ReorderCategories(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + var req struct { + IDs []int `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil || len(req.IDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Liste d'IDs requise"}) + return + } + + if err := database.ReorderCategories(req.IDs); err != nil { + log.Printf("❌ [CATEGORIES] Reorder erreur: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du réordonnancement"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + func DeleteCategory(c *gin.Context) { database := c.MustGet("database").(*db.Database) diff --git a/backend/gestion/handlers/client_tracking.go b/backend/gestion/handlers/client_tracking.go index ddaf52a4..7c983c98 100644 --- a/backend/gestion/handlers/client_tracking.go +++ b/backend/gestion/handlers/client_tracking.go @@ -209,7 +209,7 @@ func buildTimeline(logs []map[string]any) []gin.H { for _, logEntry := range logs { status, _ := logEntry["status"].(string) message, _ := logEntry["message"].(string) - createdAt, _ := logEntry["created_at"] + createdAt := logEntry["created_at"] timeline = append(timeline, gin.H{ "status": status, diff --git a/backend/gestion/handlers/commands.go b/backend/gestion/handlers/commands.go index 5287fb75..e71ce7cc 100644 --- a/backend/gestion/handlers/commands.go +++ b/backend/gestion/handlers/commands.go @@ -482,10 +482,6 @@ func StaffApproveDelivery(c *gin.Context) { }) } -// ============================================ -// APPROBATION PAR ADMIN -// ============================================ - func ValidateDelivery(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -1145,19 +1141,11 @@ func UpdateCommandStatusAdmin(c *gin.Context) { } if req.Status == "cancelled" { - current, errCmd := database.GetCommandByID(commandID) - if errCmd == nil { - currentStatus, _ := current["status"].(string) - alreadyDone := currentStatus == "cancelled" || currentStatus == "approved" || currentStatus == "livre" - if !alreadyDone { - if err := database.RestoreCommandStock(commandID); err != nil { - log.Printf("⚠️ [STATUS_ADMIN] Erreur restauration stock cmd %d: %v", commandID, err) - } - } + if err := database.CancelCommandByAdminAtomic(commandID); err != nil { + utils.ServerErr(c, "Impossible d'annuler la commande", err) + return } - } - - if err := database.UpdateCommandStatus(commandID, req.Status); err != nil { + } else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil { utils.ServerErr(c, "Impossible de mettre à jour le statut", err) return } diff --git a/backend/gestion/handlers/crypto_payment.go b/backend/gestion/handlers/crypto_payment.go index dc386893..9a4ab89e 100644 --- a/backend/gestion/handlers/crypto_payment.go +++ b/backend/gestion/handlers/crypto_payment.go @@ -12,11 +12,12 @@ import ( "github.com/gin-gonic/gin" ) -// IPNWebhook - POST /api/v1/webhook/nowpayments +// IPNWebhook - POST /api/v1/webhooks/nowpayments func IPNWebhook(c *gin.Context) { database := c.MustGet("database").(*db.Database) - np, ok := c.MustGet("nowpayments").(*services.NowPaymentsClient) - if !ok || np == nil { + npRaw, npExists := c.Get("nowpayments") + np, ok := npRaw.(*services.NowPaymentsClient) + if !npExists || !ok || np == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "paiement crypto non configuré"}) return } diff --git a/backend/gestion/handlers/deleviry.go b/backend/gestion/handlers/deleviry.go index 3d8d8fca..4e08fe1d 100644 --- a/backend/gestion/handlers/deleviry.go +++ b/backend/gestion/handlers/deleviry.go @@ -4,13 +4,13 @@ import ( "encoding/json" "fmt" "gestion/db" + "gestion/models" "gestion/services" "gestion/utils" "log" "net/http" "slices" "strconv" - "time" "github.com/gin-gonic/gin" ) @@ -40,14 +40,27 @@ func GetMyDeliveries(c *gin.Context) { return } + // Collecter tous les IDs et usernames en une passe pour éviter les N+1 + commandIDs := make([]int, 0, len(commands)) + clientUsernames := make([]string, 0, len(commands)) + for _, cmd := range commands { + if cid, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])); cid > 0 { + commandIDs = append(commandIDs, cid) + } + if u, _ := cmd["username"].(string); u != "" { + clientUsernames = append(clientUsernames, u) + } + } + allItems, _ := database.GetCommandItemsBatch(commandIDs) + allClients, _ := database.GetClientsByUsernames(clientUsernames) + filteredCommands := make([]gin.H, len(commands)) for i, cmd := range commands { commandID, _ := strconv.Atoi(fmt.Sprintf("%v", cmd["id"])) - items, _ := database.GetCommandItems(commandID) + items := allItems[commandID] - // Client info SANS téléphone clientUsername, _ := cmd["username"].(string) - client, _ := database.GetClientByUsername(clientUsername) + client := allClients[clientUsername] clientInfo := gin.H{"nom": "Client", "prenom": ""} if client != nil { @@ -244,7 +257,7 @@ func UpdateDeliveryStatus(c *gin.Context) { distance := utils.CalculateDistance(req.Latitude, req.Longitude, destLat, destLon) log.Printf("📍 [GPS] Distance: %.2f m", distance) - if distance > 100 { + if distance > 350 { c.JSON(http.StatusBadRequest, gin.H{ "error": "Vous êtes trop loin de la destination", "current_distance": fmt.Sprintf("%.2f", distance), @@ -259,22 +272,34 @@ func UpdateDeliveryStatus(c *gin.Context) { } } - // Mettre à jour le statut - if err := database.UpdateCommandStatus(commandID, req.Status); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Erreur mise à jour", - }) - return - } - + // Mettre à jour le statut. + // Le cas "cancelled" passe par une transaction atomique dédiée (transition + + // remboursement stock), pour empêcher tout double remboursement en cas de + // double appel (double-tap, retry réseau, commande déjà annulée ailleurs). if req.Status == "cancelled" { + alreadyCancelled, prevStatus, cancelErr := database.CancelDeliveryByLivreurAtomic(commandID) + if cancelErr != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Erreur mise à jour", + }) + return + } + if alreadyCancelled { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "Commande déjà annulée", + "command_id": commandID, + "status": "cancelled", + }) + return + } + cancelMsg := req.Notes if cancelMsg == "" { cancelMsg = "Annulé par le livreur" } database.SetCommandCancelReason(commandID, fmt.Sprintf("[Livreur: %s] %s", usernameStr, cancelMsg)) - prevStatus, _ := command["status"].(string) if prevStatus == "arrived" || prevStatus == "livre" { clientUsername, _ := command["username"].(string) if clientUsername != "" { @@ -285,6 +310,11 @@ func UpdateDeliveryStatus(c *gin.Context) { } } } + } else if err := database.UpdateCommandStatus(commandID, req.Status); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Erreur mise à jour", + }) + return } // ✅ SI PASSAGE EN "EN_ROUTE" → CALCULER ET DÉFINIR L'ETA @@ -441,12 +471,8 @@ func UpdateDeliveryStatus(c *gin.Context) { database.CompleteDeliveryAndProcessNext(usernameStr, commandID) case "cancelled": + // Transition + remboursement stock déjà effectués atomiquement plus haut. log.Printf("🚫 Livraison annulée par livreur - Nettoyage queue cmd %d", commandID) - if err := database.RestoreCommandStock(commandID); err != nil { - log.Printf("⚠️ [STATUS_LIVREUR] Erreur restauration stock cmd %d: %v", commandID, err) - } else { - log.Printf("✅ [STATUS_LIVREUR] Stock restauré pour cmd %d", commandID) - } database.CompleteDeliveryAndProcessNext(usernameStr, commandID) case "arrived": @@ -526,10 +552,8 @@ func ReportDeliveryIssue(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"success": true, "issue": issue}) } -// GET /api/v1/livreur/stats func GetMyDeliveryStats(c *gin.Context) { database := c.MustGet("database").(*db.Database) - username, exists := c.Get("username") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) @@ -540,66 +564,30 @@ func GetMyDeliveryStats(c *gin.Context) { return } usernameStr := username.(string) - gdb := database.GDB - type DayRow struct { - Day time.Time `gorm:"column:day"` - Count int `gorm:"column:count"` - Revenue float64 `gorm:"column:revenue"` - } - type WeekRow struct { - WeekNum int `gorm:"column:week_num"` - Year int `gorm:"column:year"` - Count int `gorm:"column:count"` - Revenue float64 `gorm:"column:revenue"` - } - type MonthRow struct { - MonthNum int `gorm:"column:month_num"` - Year int `gorm:"column:year"` - Count int `gorm:"column:count"` - Revenue float64 `gorm:"column:revenue"` + var dayRows []models.DayRowWithResult + if err := database.GetMyDeliveryStatsPerDay(&dayRows, usernameStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats jour"}) + return } - var dayRows []DayRow - gdb.Raw(` - SELECT DATE(updated_at) AS day, - COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE livreur_assign = ? - AND status IN ('livre', 'approved') - AND updated_at >= NOW() - INTERVAL '30 days' - GROUP BY DATE(updated_at) - ORDER BY day - `, usernameStr).Scan(&dayRows) + var weekRows []models.WeekRow + if err := database.GetMyDeliveryStatsPerWeek(&weekRows, usernameStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats semaine"}) + return + } - var weekRows []WeekRow - gdb.Raw(` - SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num, - EXTRACT(YEAR FROM updated_at)::int AS year, - COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE livreur_assign = ? - AND status IN ('livre', 'approved') - AND updated_at >= NOW() - INTERVAL '12 weeks' - GROUP BY week_num, year - ORDER BY year, week_num - `, usernameStr).Scan(&weekRows) + var monthRows []models.MonthRow + if err := database.GetMyDeliveryStatsPerMonth(&monthRows, usernameStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats mois"}) + return + } - var monthRows []MonthRow - gdb.Raw(` - SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num, - EXTRACT(YEAR FROM updated_at)::int AS year, - COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE livreur_assign = ? - AND status IN ('livre', 'approved') - AND updated_at >= NOW() - INTERVAL '12 months' - GROUP BY month_num, year - ORDER BY year, month_num - `, usernameStr).Scan(&monthRows) + var todayRow models.TodayRow + if err := database.GetMyDeliveryStatsToday(&todayRow, usernameStr); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération stats du jour"}) + return + } monthNames := [13]string{"", "Jan", "Fév", "Mar", "Avr", "Mai", "Jun", "Jul", "Aoû", "Sep", "Oct", "Nov", "Déc"} @@ -635,9 +623,11 @@ func GetMyDeliveryStats(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "success": true, - "by_day": byDay, - "by_week": byWeek, - "by_month": byMonth, + "success": true, + "by_day": byDay, + "by_week": byWeek, + "by_month": byMonth, + "today_count": todayRow.Count, + "today_revenue": todayRow.Revenue, }) } diff --git a/backend/gestion/handlers/delivery_admin.go b/backend/gestion/handlers/delivery_admin.go index a905908a..3439a2df 100644 --- a/backend/gestion/handlers/delivery_admin.go +++ b/backend/gestion/handlers/delivery_admin.go @@ -22,7 +22,6 @@ import ( func GetDeliveryPersonDetails(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ SÉCURITÉ: Admin seulement userRole := c.GetString("role") if userRole != "admin" && userRole != "cabine" && userRole != "livreur" { log.Printf("❌ [GET_DELIVERY_DETAILS] Accès refusé - role=%s", userRole) @@ -63,9 +62,9 @@ func GetDeliveryPersonDetails(c *gin.Context) { // Utiliser la fonction GPS existante lat, lon, err := database.GetDeliveryPersonLocation(username) - var locationInfo map[string]interface{} + var locationInfo map[string]any if err == nil { - locationInfo = map[string]interface{}{ + locationInfo = map[string]any{ "latitude": lat, "longitude": lon, } diff --git a/backend/gestion/handlers/eta.go b/backend/gestion/handlers/eta.go index ce650012..11b55cc0 100644 --- a/backend/gestion/handlers/eta.go +++ b/backend/gestion/handlers/eta.go @@ -85,7 +85,6 @@ func GetOrderETA(c *gin.Context) { return } - // 4️⃣ VÉRIFIER LES DROITS D'ACCÈS cmdUsername, _ := command["username"].(string) userRole := c.GetString("role") @@ -112,10 +111,8 @@ func GetOrderETA(c *gin.Context) { } } - // 5️⃣ VÉRIFIER LE STATUT DE LA COMMANDE cmdStatus, _ := command["status"].(string) - // ✅ CORRECTION: Vérifier si commande terminée if cmdStatus == "livre" || cmdStatus == "delivered" || cmdStatus == "approved" { log.Printf("ℹ️ [ETA] Commande déjà %s - pas d'ETA applicable", cmdStatus) c.JSON(http.StatusOK, gin.H{ @@ -129,7 +126,6 @@ func GetOrderETA(c *gin.Context) { return } - // Pour pending/assigned: pas encore de position livreur disponible if cmdStatus == "pending" || cmdStatus == "assigned" { log.Printf("⏳ [ETA] Commande %s - pas d'ETA disponible", cmdStatus) c.JSON(http.StatusOK, gin.H{ @@ -142,7 +138,6 @@ func GetOrderETA(c *gin.Context) { return } - // Pour arrived: livreur sur place, ETA non pertinent if cmdStatus == "arrived" { log.Printf("ℹ️ [ETA] Commande arrived - livreur déjà sur place") c.JSON(http.StatusOK, gin.H{ @@ -154,9 +149,7 @@ func GetOrderETA(c *gin.Context) { }) return } - // Pour en_route: calcul ETA réel via position du livreur - // 6️⃣ VÉRIFIER LE CACHE REDIS POUR ETA etaKey := fmt.Sprintf("command:eta:%d", commandID) etaData, err := db.Redis.HGetAll(db.RedisCtx, etaKey).Result() @@ -197,10 +190,8 @@ func GetOrderETA(c *gin.Context) { } } - // 7️⃣ Pas de cache valide - Recalculer l'ETA log.Printf("🔄 [ETA] Cache miss ou expiré - Recalcul de l'ETA...") - // Récupérer coordonnées destination var destLat, destLon float64 destCacheKey := fmt.Sprintf("command:destination:%d", commandID) @@ -248,13 +239,10 @@ func GetOrderETA(c *gin.Context) { toCoords := services.Coordinates{Latitude: destLat, Longitude: destLon} - // Cas 1 : GPS livreur disponible livreurLocation, gpsErr := geoService.GetDeliveryPersonLocation(livreurAssign) if gpsErr != nil { - // Cas 2 : GPS absent → dernière adresse de livraison lastLat, lastLon, lastErr := database.GetLastDeliveryCoords(livreurAssign) if lastErr != nil || lastLat == 0 { - // Cas 3 : Aucune position → cache périmé ou message log.Printf("⚠️ [ETA] Aucune position disponible pour %s", livreurAssign) c.JSON(http.StatusOK, returnStaleOrUnavailable(commandID, cmdStatus, etaData)) return @@ -263,7 +251,6 @@ func GetOrderETA(c *gin.Context) { log.Printf("📍 [ETA] Position depuis dernière livraison: (%.6f, %.6f)", lastLat, lastLon) } - // Calculer ETA avec TomTom log.Printf("🛣️ [ETA] Calcul TomTom: (%.6f, %.6f) -> (%.6f, %.6f)", livreurLocation.Latitude, livreurLocation.Longitude, toCoords.Latitude, toCoords.Longitude) @@ -274,11 +261,10 @@ func GetOrderETA(c *gin.Context) { etaMinutes = services.CalculateETA(distanceKm) } - // Sauvegarder en cache now := time.Now() arrivalTime := now.Add(time.Duration(etaMinutes) * time.Minute) - etaCache := map[string]interface{}{ + etaCache := map[string]any{ "command_id": commandID, "eta_minutes": etaMinutes, "updated_at": now.Unix(), diff --git a/backend/gestion/handlers/geoloca.go b/backend/gestion/handlers/geoloca.go index 70245fe5..61a0cc9a 100644 --- a/backend/gestion/handlers/geoloca.go +++ b/backend/gestion/handlers/geoloca.go @@ -169,10 +169,6 @@ func FindNearestDeliveryPerson(c *gin.Context) { }) } -// ============================================ -// LISTE TOUS LES LIVREURS TRIÉS PAR DISTANCE -// ============================================ - // GetAllDeliveryDistances retourne tous les livreurs triés par distance func GetAllDeliveryDistances(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -258,9 +254,6 @@ func GetAllDeliveryDistances(c *gin.Context) { }) } -// ============================================ -// AUTO-ASSIGNATION INTELLIGENTE AVEC QUEUE MULTI-COMMANDES -// ============================================ func AutoAssignNearestDeliveryPerson(c *gin.Context) { database := c.MustGet("database").(*db.Database) geoService := c.MustGet("geoService").(*services.GeoService) @@ -347,12 +340,9 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("🚗 %d livreur(s) actif(s)", activeCount) - // Récupérer les livreurs actifs avec capacité disponible activeLivreurs, err := database.GetAllActiveDeliveryPersons() - // Si aucun livreur avec capacité disponible if err != nil || len(activeLivreurs) == 0 { - // Cas 1: Un seul livreur actif -> pas de limite if activeCount == 1 { singleDeliveryman, err := database.GetSingleActiveDeliveryman() if err != nil { @@ -371,7 +361,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // ✅ Passer les coordonnées à la fonction d'assignation err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, singleDeliveryman, travelTime, location.Latitude, location.Longitude, address) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -386,7 +375,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("✅ Commande %d assignée au seul livreur actif %s (%.2f km)", commandID, singleDeliveryman, distance) - // ✅ CORRECTION: Utiliser etaData directement sans accès aux clés c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Commande assignée au seul livreur actif (sans limite)", @@ -399,7 +387,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { "single_driver": true, "traffic_aware": true, }, - "eta": etaData, // ✅ Directement l'objet complet + "eta": etaData, "delivery_address": address, "coordinates": gin.H{ "latitude": location.Latitude, @@ -410,13 +398,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // Cas 2: Plusieurs livreurs mais tous à capacité max -> Distribution forcée allAtCapacity, numActive, _ := database.AreAllDeliverymenAtCapacity() if allAtCapacity && numActive > 1 { log.Printf("⚠️ Tous les %d livreurs sont à capacité max - Distribution forcée", numActive) - // Trouver le livreur le moins chargé (même s'il dépasse 10) leastLoaded, currentSize, err := database.GetLeastLoadedDeliverymanForced() if err != nil { c.JSON(http.StatusNotFound, gin.H{ @@ -424,8 +410,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { }) return } - - // Calculer le temps de trajet avec TomTom travelTime, distance, err := calculateTravelTimeWithTomTom(geoService, leastLoaded, location.Latitude, location.Longitude) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -433,8 +417,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { }) return } - - // ✅ Assigner de force avec coordonnées err = database.ForceAssignCommandToDeliverymanWithCoords(commandID, leastLoaded, travelTime, location.Latitude, location.Longitude, address) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -449,7 +431,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("✅ FORCE: Commande %d assignée à %s (capacité dépassée: %d, %.2f km)", commandID, leastLoaded, currentSize+1, distance) - // ✅ CORRECTION: Utiliser etaData directement c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Commande assignée par distribution forcée (capacité max dépassée)", @@ -463,7 +444,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { "over_capacity": true, "traffic_aware": true, }, - "eta": etaData, // ✅ Directement l'objet complet + "eta": etaData, "delivery_address": address, "coordinates": gin.H{ "latitude": location.Latitude, @@ -474,7 +455,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // Cas 3: Erreur générique c.JSON(http.StatusNotFound, gin.H{ "error": "Aucun livreur actif avec capacité disponible", "active_count": activeCount, @@ -483,13 +463,11 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // Cas normal: Au moins un livreur avec capacité disponible usernames := make([]string, len(activeLivreurs)) for i, livreur := range activeLivreurs { usernames[i] = livreur.Username } - // Trouver le livreur le plus proche (calcul rapide) nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames) if err != nil { c.JSON(http.StatusNotFound, gin.H{ @@ -498,7 +476,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { return } - // Recalculer l'ETA avec TomTom pour plus de précision travelTime, distance, err := services.GetETAWithTraffic(nearest.Location, targetCoords) if err != nil { // Fallback sur le calcul initial @@ -509,7 +486,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("🎯 Livreur le plus proche: %s (%.2f km, ~%d min)", nearest.Username, distance, travelTime) - // ✅ Assigner à la queue du livreur avec coordonnées err = database.AssignCommandToDeliverymanQueueWithCoords(commandID, nearest.Username, travelTime, location.Latitude, location.Longitude, address) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -524,7 +500,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { log.Printf("✅ Commande %d assignée à la queue de %s", commandID, nearest.Username) - // ✅ CORRECTION: Utiliser etaData directement c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Commande assignée à la queue du livreur", @@ -537,7 +512,7 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { "single_driver": activeCount == 1, "traffic_aware": err == nil, }, - "eta": etaData, // ✅ Directement l'objet complet + "eta": etaData, "delivery_address": address, "coordinates": gin.H{ "latitude": location.Latitude, @@ -547,8 +522,6 @@ func AutoAssignNearestDeliveryPerson(c *gin.Context) { }) } -// AutoAssignAllPendingCommands assigne toutes les commandes en attente -// POST /api/v2/admin/protected/commands/auto-assign-all func AutoAssignAllPendingCommands(c *gin.Context) { database := c.MustGet("database").(*db.Database) geoService := c.MustGet("geoService").(*services.GeoService) @@ -559,7 +532,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { return } - // Récupérer toutes les commandes pending commands, err := database.GetAllCommands("pending", "") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -585,7 +557,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { for _, cmd := range commands { commandID, ok := cmd["id"].(int) if !ok { - // Essayer avec float64 if idFloat, ok := cmd["id"].(float64); ok { commandID = int(idFloat) } else { @@ -593,7 +564,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { } } - // Récupérer l'adresse address, ok := cmd["adresse"].(string) if !ok || address == "" || address == "Adresse non spécifiée" { failed = append(failed, gin.H{ @@ -603,7 +573,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { continue } - // Géocoder l'adresse location, err := geoService.GeocodeAddress(address) if err != nil { failed = append(failed, gin.H{ @@ -618,7 +587,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { Longitude: location.Longitude, } - // Récupérer les livreurs actifs activeLivreurs, err := database.GetAllActiveDeliveryPersons() if err != nil || len(activeLivreurs) == 0 { failed = append(failed, gin.H{ @@ -633,7 +601,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { usernames[i] = livreur.Username } - // Trouver le livreur le plus proche (version rapide pour assignation masse) nearest, err := geoService.FindNearestDeliveryPersonFast(targetCoords, usernames) if err != nil { failed = append(failed, gin.H{ @@ -643,7 +610,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { continue } - // Pour l'assignation en masse, on utilise le calcul rapide travelTime := nearest.EstimatedTime distance := nearest.Distance @@ -657,13 +623,10 @@ func AutoAssignAllPendingCommands(c *gin.Context) { continue } - // Mettre à jour le statut du livreur database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) - - // Récupérer l'ETA - ✅ CORRECTION: Gérer les types correctement etaData, _ := database.GetCommandETA(commandID) - var totalETA, waitTime interface{} + var totalETA, waitTime any totalETA = "N/A" waitTime = "N/A" @@ -688,7 +651,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { log.Printf("✅ Commande %d -> %s (ETA: %v min)", commandID, nearest.Username, totalETA) } - // Récupérer l'overview des queues queuesOverview, _ := database.GetAllQueuesOverview() c.JSON(http.StatusOK, gin.H{ @@ -705,7 +667,6 @@ func AutoAssignAllPendingCommands(c *gin.Context) { } // GetAllDeliveryQueues retourne l'état de toutes les queues des livreurs -// GET /api/v2/admin/protected/delivery/queues func GetAllDeliveryQueues(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -723,7 +684,6 @@ func GetAllDeliveryQueues(c *gin.Context) { return } - // Récupérer les détails de chaque livreur var deliverymenDetails []gin.H keys, _ := db.Redis.Keys(db.RedisCtx, "delivery:status:*").Result() @@ -734,7 +694,7 @@ func GetAllDeliveryQueues(c *gin.Context) { // Récupérer le statut statusData, _ := db.Redis.Get(db.RedisCtx, key).Result() - var status map[string]interface{} + var status map[string]any if statusData != "" { json.Unmarshal([]byte(statusData), &status) } diff --git a/backend/gestion/handlers/gps.go b/backend/gestion/handlers/gps.go index 70382752..146443dc 100644 --- a/backend/gestion/handlers/gps.go +++ b/backend/gestion/handlers/gps.go @@ -34,7 +34,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) { log.Printf("🗺️ [MAP_LINKS] Demande pour livreur: %s", username) - // Récupérer la position GPS du livreur lat, lon, err := database.GetDeliveryPersonLocation(username) if err != nil { log.Printf("❌ [MAP_LINKS] Erreur position: %v", err) @@ -46,7 +45,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) { return } - // Validation des coordonnées if lat == 0 && lon == 0 { log.Printf("⚠️ [MAP_LINKS] Coordonnées invalides (0,0) pour %s", username) c.JSON(http.StatusNotFound, gin.H{ @@ -57,7 +55,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) { return } - // Générer les liens de cartes mapLinks := database.GenerateMapLinks(lat, lon, username) log.Printf("✅ [MAP_LINKS] Liens générés pour %s: (%.6f, %.6f)", username, lat, lon) @@ -76,8 +73,6 @@ func GetDeliveryPersonMapLinks(c *gin.Context) { }) } -// GetLivreurNavLink retourne le lien Waze App pour une livraison assignée au livreur connecté -// GET /api/v1/livreur/deliveries/:id/nav-link func GetLivreurNavLink(c *gin.Context) { database := c.MustGet("database").(*db.Database) username := c.GetString("username") @@ -100,7 +95,6 @@ func GetLivreurNavLink(c *gin.Context) { return } - // Priorité : coordonnées GPS de la destination var wazeLink string destLat, hasLat := command["dest_latitude"].(float64) destLon, hasLon := command["dest_longitude"].(float64) diff --git a/backend/gestion/handlers/history.go b/backend/gestion/handlers/history.go index 8fbc858b..292bffa2 100644 --- a/backend/gestion/handlers/history.go +++ b/backend/gestion/handlers/history.go @@ -4,92 +4,18 @@ import ( "fmt" "gestion/db" "log" + "maps" "net/http" "strconv" "github.com/gin-gonic/gin" ) -func GetMyCompletedOrders(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("❌ [HISTORY] Utilisateur non authentifié") - c.JSON(http.StatusUnauthorized, gin.H{ - "error": "Authentification requise", - }) - return - } - - usernameStr := username.(string) - log.Printf("📚 [HISTORY] Récupération historique pour: %s", usernameStr) - - // ✅ Récupérer les commandes terminées (approved) - commands, err := database.GetCompletedCommandsByUsername(usernameStr) - if err != nil { - log.Printf("❌ [HISTORY] Erreur: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Erreur lors de la récupération de l'historique", - }) - return - } - - log.Printf("✅ [HISTORY] %d commandes terminées trouvées", len(commands)) - - // ✅ Récupérer les infos client pour statistiques - client, err := database.GetClientByUsername(usernameStr) - - // ✅ Récupérer les noms et clés des pools de points - poolNames := []string{"Pool 1", "Pool 2"} - var poolKeys []string - if settings, sErr := database.GetSettings(); sErr == nil && len(settings.PointsPools) > 0 { - poolNames = make([]string, len(settings.PointsPools)) - poolKeys = make([]string, len(settings.PointsPools)) - for i, p := range settings.PointsPools { - poolNames[i] = p.Name - poolKeys[i] = p.Key - } - } - - response := gin.H{ - "success": true, - "commands": commands, - "count": len(commands), - } - - if err == nil && client != nil { - // Construire le tableau depuis points_extra[poolKey] pour tous les pools (stockage dynamique) - poolPoints := make([]int, len(poolKeys)) - for i, key := range poolKeys { - if key != "" { - poolPoints[i] = client.PointsExtra[key] - } - } - - log.Printf("📊 [HISTORY] pool_names=%v pool_keys=%v pool_points=%v extra=%v", - poolNames, poolKeys, poolPoints, client.PointsExtra) - - response["client_stats"] = gin.H{ - "username": client.Username, - "total_commands": client.Command, - "points_extra": client.PointsExtra, - "pool_points": poolPoints, - "pool_names": poolNames, - "penalties": client.Amende, - } - } - - c.JSON(http.StatusOK, response) -} - // GetMyCompletedOrdersWithItems récupère l'historique avec les détails des items // GET /api/v1/my-commands/history/detailed func GetMyCompletedOrdersWithItems(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("❌ [HISTORY_DETAILED] Utilisateur non authentifié") @@ -102,7 +28,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { usernameStr := username.(string) log.Printf("📚 [HISTORY_DETAILED] Récupération historique détaillé pour: %s", usernameStr) - // ✅ Récupérer les commandes terminées commands, err := database.GetCompletedCommandsByUsername(usernameStr) if err != nil { log.Printf("❌ [HISTORY_DETAILED] Erreur: %v", err) @@ -112,7 +37,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { return } - // ✅ Enrichir chaque commande avec ses items var enrichedCommands []map[string]any for _, command := range commands { @@ -121,7 +45,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { continue } - // Récupérer les items de cette commande items, err := database.GetCommandItems(commandID) if err != nil { log.Printf("⚠️ [HISTORY_DETAILED] Erreur items pour cmd %d: %v", commandID, err) @@ -130,9 +53,7 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { // Ajouter les items à la commande enrichedCommand := make(map[string]any) - for k, v := range command { - enrichedCommand[k] = v - } + maps.Copy(enrichedCommand, command) enrichedCommand["items"] = items enrichedCommand["items_count"] = len(items) @@ -141,7 +62,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { log.Printf("✅ [HISTORY_DETAILED] %d commandes enrichies", len(enrichedCommands)) - // ✅ Récupérer les infos client client, err := database.GetClientByUsername(usernameStr) response := gin.H{ @@ -168,7 +88,6 @@ func GetMyCompletedOrdersWithItems(c *gin.Context) { func GetOrderHistory(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("❌ [ORDER_HISTORY] Utilisateur non authentifié") @@ -180,7 +99,6 @@ func GetOrderHistory(c *gin.Context) { usernameStr := username.(string) - // Récupérer l'ID de la commande var commandID int if _, err := fmt.Sscanf(c.Param("id"), "%d", &commandID); err != nil { c.JSON(http.StatusBadRequest, gin.H{ @@ -191,7 +109,6 @@ func GetOrderHistory(c *gin.Context) { log.Printf("📜 [ORDER_HISTORY] Récupération historique cmd %d pour %s", commandID, usernameStr) - // ✅ Vérifier que la commande existe command, err := database.GetCommandByID(commandID) if err != nil { log.Printf("❌ [ORDER_HISTORY] Commande non trouvée") @@ -201,7 +118,6 @@ func GetOrderHistory(c *gin.Context) { return } - // ✅ Vérifier que la commande appartient au client cmdUsername, ok := command["username"].(string) if !ok || cmdUsername != usernameStr { log.Printf("❌ [ORDER_HISTORY] Accès refusé - cmd appartient à %s, pas à %s", cmdUsername, usernameStr) @@ -211,18 +127,16 @@ func GetOrderHistory(c *gin.Context) { return } - // ✅ Récupérer les logs de la commande logs, err := database.GetCommandLogs(commandID) if err != nil { log.Printf("⚠️ [ORDER_HISTORY] Erreur logs: %v", err) - logs = []map[string]interface{}{} + logs = []map[string]any{} } - // ✅ Récupérer les items items, err := database.GetCommandItems(commandID) if err != nil { log.Printf("⚠️ [ORDER_HISTORY] Erreur items: %v", err) - items = []map[string]interface{}{} + items = []map[string]any{} } log.Printf("✅ [ORDER_HISTORY] Cmd %d: %d logs, %d items", commandID, len(logs), len(items)) diff --git a/backend/gestion/handlers/livreur_login_history.go b/backend/gestion/handlers/livreur_login_history.go new file mode 100644 index 00000000..adaf26fb --- /dev/null +++ b/backend/gestion/handlers/livreur_login_history.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "gestion/db" + "gestion/utils" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" +) + +type loginHistoryWeek struct { + Week int `json:"week"` + Entries []db.LoginHistoryEntry `json:"entries"` +} + +// GetLivreurLoginHistory retourne l'historique de connexion d'un livreur pour un mois donné, +// regroupé par semaine ISO (détail complet, pas d'agrégation par compteur). +func GetLivreurLoginHistory(c *gin.Context) { + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"}) + return + } + + now := time.Now() + year := now.Year() + month := int(now.Month()) + + if y := c.Query("year"); y != "" { + parsed, err := strconv.Atoi(y) + if err != nil || parsed < 2000 || parsed > 2100 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Année invalide"}) + return + } + year = parsed + } + if m := c.Query("month"); m != "" { + parsed, err := strconv.Atoi(m) + if err != nil || parsed < 1 || parsed > 12 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Mois invalide"}) + return + } + month = parsed + } + + database := c.MustGet("database").(*db.Database) + entries, err := database.GetLivreurLoginHistoryByMonth(username, year, month) + if err != nil { + utils.ServerErr(c, "Erreur récupération historique de connexion", err) + return + } + + weekOrder := make([]int, 0) + weekMap := make(map[int]*loginHistoryWeek) + for _, e := range entries { + _, isoWeek := e.CreatedAt.ISOWeek() + w, ok := weekMap[isoWeek] + if !ok { + w = &loginHistoryWeek{Week: isoWeek} + weekMap[isoWeek] = w + weekOrder = append(weekOrder, isoWeek) + } + w.Entries = append(w.Entries, e) + } + + weeks := make([]*loginHistoryWeek, 0, len(weekOrder)) + for _, wk := range weekOrder { + weeks = append(weeks, weekMap[wk]) + } + + c.JSON(http.StatusOK, gin.H{ + "username": username, + "year": year, + "month": month, + "weeks": weeks, + "count": len(entries), + }) +} diff --git a/backend/gestion/handlers/notifications.go b/backend/gestion/handlers/notifications.go index e26d2337..4f614770 100644 --- a/backend/gestion/handlers/notifications.go +++ b/backend/gestion/handlers/notifications.go @@ -20,7 +20,6 @@ func GetClientNotifications(c *gin.Context) { notifKey := "notifications:" + username - // Récupérer toutes les notifications (max 50) results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result() if err != nil { log.Printf("❌ [GET_NOTIFICATIONS] Erreur Redis: %v", err) @@ -128,7 +127,7 @@ func MarkLivreurNotificationsRead(c *gin.Context) { markedCount := 0 for i, raw := range results { - var n map[string]interface{} + var n map[string]any if err := json.Unmarshal([]byte(raw), &n); err != nil { continue } @@ -171,7 +170,7 @@ func MarkNotificationsRead(c *gin.Context) { // Réécrire chaque notification avec read=true markedCount := 0 for i, raw := range results { - var n map[string]interface{} + var n map[string]any if err := json.Unmarshal([]byte(raw), &n); err != nil { continue } diff --git a/backend/gestion/handlers/panier.go b/backend/gestion/handlers/panier.go index bcc3175a..c581ecf5 100644 --- a/backend/gestion/handlers/panier.go +++ b/backend/gestion/handlers/panier.go @@ -22,9 +22,6 @@ type BasketsRequest struct { Quantity float64 `json:"quantity"` } -// ============================================ -// ✅ SÉCURISÉ: AddProductsBasket -// ============================================ // POST /api/v1/panier/add func AddProductsBasket(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -90,7 +87,6 @@ func GetAllBaskets(c *gin.Context) { return } - // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [GET_PANIER] Username manquant dans JWT") @@ -100,7 +96,6 @@ func GetAllBaskets(c *gin.Context) { authUsernameStr := authUsername.(string) - // ✅ SÉCURITÉ 2: Vérifier que c'est bien l'utilisateur de la session if username != authUsernameStr { log.Printf("❌ [GET_PANIER] ⚠️ TENTATIVE D'ACCÈS AU PANIER NON AUTORISÉE!") log.Printf(" Username du JWT: %s", authUsernameStr) @@ -111,10 +106,8 @@ func GetAllBaskets(c *gin.Context) { return } - // ✅ SÉCURITÉ 3: Forcer l'utilisation du username du JWT username = authUsernameStr - // ✅ SÉCURITÉ 4: Vérifier que c'est un CLIENT _, err := database.GetClientByUsername(username) if err != nil { log.Printf("❌ [GET_PANIER] Client inexistant: %s", username) @@ -130,7 +123,7 @@ func GetAllBaskets(c *gin.Context) { var totalAmount float64 for _, item := range baskets { - totalAmount += item.Price // price = prix total de la ligne (cumul des ajouts) + totalAmount += item.Price } log.Printf("✅ [GET_PANIER] Panier %s: %d articles, total=%.2f€", username, len(baskets), totalAmount) @@ -156,7 +149,6 @@ func DeleteProductFromBasket(c *gin.Context) { return } - // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [DEL_PANIER] Username manquant dans JWT") @@ -168,7 +160,6 @@ func DeleteProductFromBasket(c *gin.Context) { log.Printf("🗑️ [DEL_PANIER] Suppression article: id=%d, client=%s", req.ID, authUsernameStr) - // ✅ SÉCURITÉ 2: Vérifier que l'article appartient à ce client itemUsername, err := database.GetBasketItemOwner(req.ID) if err != nil { @@ -187,7 +178,6 @@ func DeleteProductFromBasket(c *gin.Context) { return } - // Supprimer l'article err = database.DeleteProductFromBasket(req.ID) if err != nil { utils.ServerErr(c, "Erreur lors de la suppression", err) @@ -205,7 +195,6 @@ func DeleteProductFromBasket(c *gin.Context) { func ClearBasket(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ SÉCURITÉ 1: Récupérer username depuis le contexte (du JWT validé) authUsername, hasAuth := c.Get("username") if !hasAuth { log.Printf("❌ [CLEAR_PANIER] Username manquant dans JWT") @@ -262,8 +251,8 @@ func ValidateBasket(c *gin.Context) { var req struct { DeliveryAddress string `json:"delivery_address" binding:"required"` UseReferralBalance bool `json:"use_referral_balance"` - PaymentMethod string `json:"payment_method"` // "cash" (défaut) ou "crypto" - PayCurrency string `json:"pay_currency"` // ex: "btc", "eth", "ltc" (requis si crypto) + PaymentMethod string `json:"payment_method"` + PayCurrency string `json:"pay_currency"` } if err := c.ShouldBindJSON(&req); err != nil || req.DeliveryAddress == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse de livraison requise"}) @@ -277,7 +266,6 @@ func ValidateBasket(c *gin.Context) { } req.DeliveryAddress = cmd.DeliveryAddress - // Vérifier que le client a lié son compte Telegram (seulement si les notifications sont activées) if services.TelegramBot != nil && services.TelegramBot.IsNotificationsEnabled() { if _, linked, err := database.GetClientTelegramChatID(usernameStr); err != nil || !linked { c.JSON(http.StatusForbidden, gin.H{"error": "Vous devez lier votre compte Telegram avant de commander"}) @@ -287,9 +275,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("🛒 [CHECKOUT] Début checkout pour: %s", usernameStr) - // ============================================ - // 1️⃣ Vérifier que le panier n'est pas vide - // ============================================ items, err := database.GetBasketItems(usernameStr) if err != nil { utils.ServerErr(c, "Impossible de récupérer le panier", err) @@ -304,9 +289,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items)) - // ============================================ - // 1️⃣b Calculer le total et vérifier la présence d'au moins un article payant - // ============================================ var cartTotal float64 for _, item := range items { if price, ok := item["price"].(float64); ok { @@ -314,7 +296,6 @@ func ValidateBasket(c *gin.Context) { } } - // Détecter si le panier contient un article récompense (prix 0) hasRewardItem := false for _, item := range items { if price, ok := item["price"].(float64); ok && price == 0 { @@ -322,17 +303,13 @@ func ValidateBasket(c *gin.Context) { break } } - // Si récompense présente mais aucun produit payant → refuser if hasRewardItem && cartTotal <= 0 { log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr) c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"}) return } - - // 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) @@ -366,8 +343,6 @@ func ValidateBasket(c *gin.Context) { return } - // 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 @@ -399,7 +374,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("✅ [CHECKOUT] Crédit parrainage -%.2f€ débité pour %s", referralUsed, usernameStr) } - // Vérifier que tous les produits du panier ont encore un prix actif unavailable, err := database.GetUnavailableBasketItems(usernameStr) if err != nil { utils.ServerErr(c, "Erreur vérification produits", err) @@ -414,11 +388,11 @@ func ValidateBasket(c *gin.Context) { return } - // Vérification option crypto isCrypto := req.PaymentMethod == "crypto" if isCrypto { - np, npOk := c.MustGet("nowpayments").(*services.NowPaymentsClient) - if !npOk || np == nil { + npRaw, npExists := c.Get("nowpayments") + np, npOk := npRaw.(*services.NowPaymentsClient) + if !npExists || !npOk || np == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Paiement crypto non disponible"}) return } @@ -434,6 +408,10 @@ func ValidateBasket(c *gin.Context) { _ = database.CreditClientReferral(usernameStr, referralUsed) } log.Printf("❌ [CHECKOUT] Erreur création commande: %v", err) + if strings.Contains(err.Error(), "stock insuffisant") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Le stock ou le produit n'est plus disponible, repasse commande"}) + return + } c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création commande"}) return } @@ -447,7 +425,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("✅ [CHECKOUT] Commande %d créée", commandID) - // Pour le paiement crypto, le panier/stock sera décrémenté à la confirmation du webhook NowPayments. if isCrypto { np := c.MustGet("nowpayments").(*services.NowPaymentsClient) ipnURL := fmt.Sprintf("%s/api/v1/webhooks/nowpayments", getBaseURL(c)) @@ -460,7 +437,6 @@ func ValidateBasket(c *gin.Context) { } payResp, err := np.CreatePayment(payReq) if err != nil { - // Annuler la commande et restaurer le panier / parrainage _ = database.CancelCryptoCommand(commandID) if referralUsed > 0 { _ = database.CreditClientReferral(usernameStr, referralUsed) @@ -470,14 +446,21 @@ func ValidateBasket(c *gin.Context) { return } - // Passer la commande en 'pending_payment' (attente confirmation) if _, err := database.DB.Exec(`UPDATE commandes SET status = 'pending_payment', payment_method = 'crypto', updated_at = NOW() WHERE id = $1`, commandID); err != nil { log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut pending_payment: %v", err) } priceAmt, _ := payResp.PriceAmount.Float64() payAmt, _ := payResp.PayAmount.Float64() - _, _ = database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt) + if _, err := database.CreateCryptoPayment(commandID, payResp.PaymentID.String(), payResp.Status, payResp.PriceCurrency, payResp.PayCurrency, payResp.PayAddress, priceAmt, payAmt); err != nil { + log.Printf("❌ [CHECKOUT] Erreur enregistrement paiement crypto (commande %d, nowpayment %s): %v", commandID, payResp.PaymentID.String(), err) + _ = database.CancelCryptoCommand(commandID) + if referralUsed > 0 { + _ = database.CreditClientReferral(usernameStr, referralUsed) + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur interne lors de l'enregistrement du paiement"}) + return + } log.Printf("✅ [CHECKOUT] Commande %d en attente paiement crypto (%s)", commandID, req.PayCurrency) c.JSON(http.StatusCreated, gin.H{ @@ -495,32 +478,8 @@ func ValidateBasket(c *gin.Context) { return } - // Notifier immédiatement tous les admins et agents cabine go database.NotifyAllAdminCabine(commandID, usernameStr, req.DeliveryAddress) - // ============================================ - // 3️⃣ Décrémenter le stock et vider le panier - // ============================================ - err = database.ClearBasketOnCheckout(usernameStr) - if err != nil { - // Stock insuffisant au moment du checkout (concurrent) → annuler la commande - if strings.Contains(err.Error(), "stock insuffisant") { - _ = database.CancelCryptoCommand(commandID) - if referralUsed > 0 { - _ = database.CreditClientReferral(usernameStr, referralUsed) - } - log.Printf("❌ [CHECKOUT] Stock insuffisant au moment de la validation: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Désolé ! Tu as trop attendu pour passer commande! Le stock ou le produit n'est plus disponible, repasse commande"}) - return - } - utils.ServerErr(c, "Impossible de valider le panier", err) - return - } - log.Printf("🧹 [CHECKOUT] Stock décrémenté et panier vidé") - - // ============================================ - // 4️⃣ Auto-assignation livreur (optionnel) - // ============================================ var assigned bool var assignInfo gin.H @@ -540,7 +499,6 @@ func ValidateBasket(c *gin.Context) { if err == nil { log.Printf("👤 [CHECKOUT] Livreur le plus proche: %s (%.2f km)", nearest.Username, nearest.Distance) - // ✅ CORRECTION: Utiliser CalculateETAWithTomTom au lieu de GetETAWithTraffic travelTime, distance, err := services.CalculateETAWithTomTom( nearest.Location, services.Coordinates{ @@ -558,7 +516,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("⏱️ [CHECKOUT] ETA calculé: %d min, distance: %.2f km", travelTime, distance) - // Assigner la commande au livreur err = database.AssignCommandToDeliverymanQueueWithCoords( commandID, nearest.Username, @@ -571,13 +528,10 @@ func ValidateBasket(c *gin.Context) { if err != nil { log.Printf("⚠️ [CHECKOUT] Erreur assignation: %v", err) } else { - // Mettre à jour le statut du livreur err = database.SetDeliveryPersonStatus(nearest.Username, "busy", commandID) if err != nil { log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err) } - - // Notifier le livreur de la nouvelle commande notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance) if referralUsed > 0 { notifMsg += fmt.Sprintf(" | Parrainage client: -%.2f€", referralUsed) @@ -585,8 +539,6 @@ func ValidateBasket(c *gin.Context) { if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil { log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr) } - - // Notifier le client clientOrderID := database.GetClientOrderID(commandID) clientMsg := fmt.Sprintf("Ta commande #%d est prise en compte ! Merci de rester branché et vigilant sur les notifs à venir.", clientOrderID) database.NotifyClient(usernameStr, commandID, "assigned", clientMsg) @@ -609,9 +561,6 @@ func ValidateBasket(c *gin.Context) { log.Printf("⚠️ [CHECKOUT] Erreur géocodage: %v", err) } - // ============================================ - // 5️⃣ Réponse - // ============================================ newBalance, _ := database.GetClientReferralBalance(usernameStr) resp := gin.H{ "success": true, @@ -638,7 +587,6 @@ func ValidateBasket(c *gin.Context) { c.JSON(http.StatusCreated, resp) } -// getBaseURL construit l'URL de base depuis la requête en cours func getBaseURL(c *gin.Context) string { scheme := "https" if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { diff --git a/backend/gestion/handlers/parrain.go b/backend/gestion/handlers/parrain.go index 69d6192f..ab7bccc8 100644 --- a/backend/gestion/handlers/parrain.go +++ b/backend/gestion/handlers/parrain.go @@ -9,8 +9,6 @@ import ( "github.com/gin-gonic/gin" ) -// SetClientParrainAdmin — POST /api/v2/admin/protected/client/:username/parrain/set (admin) -// Assigne un parrain à un client. Le parrain reçoit settings.ReferralAmount sur son solde. func SetClientParrainAdmin(c *gin.Context) { database := c.MustGet("database").(*db.Database) targetUsername := c.Param("username") @@ -28,14 +26,12 @@ func SetClientParrainAdmin(c *gin.Context) { return } - // Vérifier que le parrain existe parrain, err := database.GetClientByUsername(req.Parrain) if err != nil || parrain == nil { c.JSON(http.StatusNotFound, gin.H{"error": "Parrain introuvable"}) return } - // Vérifier que le client n'a pas déjà un parrain existing, err := database.GetClientParrain(targetUsername) if err != nil { utils.ServerErr(c, "Erreur vérification parrain", err) @@ -46,25 +42,23 @@ func SetClientParrainAdmin(c *gin.Context) { return } - if err := database.SetClientParrain(targetUsername, req.Parrain); err != nil { + settings, _ := database.GetSettings() + creditAmount := 0.0 + if settings.ReferralEnabled && settings.ReferralAmount > 0 { + creditAmount = settings.ReferralAmount + } + + if err := database.SetClientParrainAndCredit(targetUsername, req.Parrain, creditAmount); err != nil { utils.ServerErr(c, "Erreur enregistrement parrain", err) return } - - settings, _ := database.GetSettings() - if settings.ReferralEnabled && settings.ReferralAmount > 0 { - if err := database.CreditClientReferral(req.Parrain, settings.ReferralAmount); err != nil { - log.Printf("⚠️ [PARRAIN] Impossible de créditer %s: %v", req.Parrain, err) - } else { - log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, settings.ReferralAmount) - } - } + log.Printf("✅ [PARRAIN] %s parrainé par %s → +%.2f€ crédité", targetUsername, req.Parrain, creditAmount) c.JSON(http.StatusOK, gin.H{ "message": "Parrain enregistré", "client": targetUsername, "parrain": req.Parrain, - "amount_credited": settings.ReferralAmount, + "amount_credited": creditAmount, }) } diff --git a/backend/gestion/handlers/points.go b/backend/gestion/handlers/points.go index aa5678e3..d48978f6 100644 --- a/backend/gestion/handlers/points.go +++ b/backend/gestion/handlers/points.go @@ -11,6 +11,34 @@ import ( "github.com/gin-gonic/gin" ) +// eligibleRewardProductIDs détermine, pour un pool donné, quels product_id de +// reward.RewardItems sont éligibles : sa catégorie (via CategoryConfigs) doit +// faire partie des catégories du pool, soit par whitelist explicite (ProductIDs) +// soit par correspondance de catégorie produit (AllProducts). +func eligibleRewardProductIDs(reward *models.PointsReward, poolCategories map[string]bool, productCategories map[int]string) map[int]bool { + eligible := make(map[int]bool) + if reward == nil { + return eligible + } + for _, cfg := range reward.CategoryConfigs { + if !poolCategories[cfg.Category] { + continue + } + if cfg.AllProducts { + for pid, cat := range productCategories { + if cat == cfg.Category { + eligible[pid] = true + } + } + } else { + for _, pid := range cfg.ProductIDs { + eligible[pid] = true + } + } + } + return eligible +} + // GetMyPointsRewards retourne les points et les récompenses disponibles du client connecté. // La récompense est globale : son seuil s'applique indépendamment à chaque pool. func GetMyPointsRewards(c *gin.Context) { @@ -48,14 +76,22 @@ func GetMyPointsRewards(c *gin.Context) { ProductNames []string `json:"product_names"` } + type RewardItemResponse struct { + ProductID int `json:"product_id"` + ProductName string `json:"product_name"` + Quantity float64 `json:"quantity"` + Price float64 `json:"price"` + } + type PoolInfo struct { - Key string `json:"key"` - Name string `json:"name"` - Points int `json:"points"` - RewardsEarned int `json:"rewards_earned"` - RewardsClaimed int `json:"rewards_claimed"` - RewardsAvailable int `json:"rewards_available"` - EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"` + Key string `json:"key"` + Name string `json:"name"` + Points int `json:"points"` + RewardsEarned int `json:"rewards_earned"` + RewardsClaimed int `json:"rewards_claimed"` + RewardsAvailable int `json:"rewards_available"` + EligibleConfigs []EligibleConfigResponse `json:"eligible_configs"` + EligibleRewardItems []RewardItemResponse `json:"eligible_reward_items"` } // Collecter tous les product_ids nécessaires en un seul passage @@ -73,6 +109,7 @@ func GetMyPointsRewards(c *gin.Context) { } } productNames, _ := database.GetProductNamesByIDs(allProductIDs) + productCategories, _ := database.GetProductCategoriesByIDs(allProductIDs) pools := make([]PoolInfo, 0, len(settings.PointsPools)) for _, pool := range settings.PointsPools { @@ -83,12 +120,9 @@ func GetMyPointsRewards(c *gin.Context) { if reward != nil && reward.Threshold > 0 { earned = pts / reward.Threshold available = earned - redeemed - if available < 0 { - available = 0 - } + available = max(earned-redeemed, 0) } - // Filtrer les category_configs aux seules catégories du pool poolCats := make(map[string]bool, len(pool.Categories)) for _, c := range pool.Categories { poolCats[c] = true @@ -114,24 +148,35 @@ func GetMyPointsRewards(c *gin.Context) { } } + eligibleProductIDs := eligibleRewardProductIDs(reward, poolCats, productCategories) + eligibleRewardItems := make([]RewardItemResponse, 0) + if reward != nil { + for _, item := range reward.RewardItems { + if !eligibleProductIDs[item.ProductID] { + continue + } + eligibleRewardItems = append(eligibleRewardItems, RewardItemResponse{ + ProductID: item.ProductID, + ProductName: productNames[item.ProductID], + Quantity: item.Quantity, + Price: item.Price, + }) + } + } + pools = append(pools, PoolInfo{ - Key: pool.Key, - Name: pool.Name, - Points: pts, - RewardsEarned: earned, - RewardsClaimed: redeemed, - RewardsAvailable: available, - EligibleConfigs: eligibleConfigs, + Key: pool.Key, + Name: pool.Name, + Points: pts, + RewardsEarned: earned, + RewardsClaimed: redeemed, + RewardsAvailable: available, + EligibleConfigs: eligibleConfigs, + EligibleRewardItems: eligibleRewardItems, }) } // Construire la liste des produits récompense avec leurs noms - type RewardItemResponse struct { - ProductID int `json:"product_id"` - ProductName string `json:"product_name"` - Quantity float64 `json:"quantity"` - Price float64 `json:"price"` - } var rewardMeta gin.H if reward != nil { rewardItems := make([]RewardItemResponse, 0, len(reward.RewardItems)) @@ -168,7 +213,7 @@ func ClaimMyReward(c *gin.Context) { var req struct { PoolKey string `json:"pool_key" binding:"required"` - ProductID int `json:"product_id"` // optionnel : 0 = automatique (1 seul item) + ProductID int `json:"product_id"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "pool_key requis"}) @@ -194,53 +239,86 @@ func ClaimMyReward(c *gin.Context) { return } - // Vérifier que le pool existe - poolExists := false - for _, p := range settings.PointsPools { - if p.Key == req.PoolKey { - poolExists = true + // Vérifier que le pool existe et récupérer ses catégories + var selectedPool *models.PointsPool + for i := range settings.PointsPools { + if settings.PointsPools[i].Key == req.PoolKey { + selectedPool = &settings.PointsPools[i] break } } - if !poolExists { + if selectedPool == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Pool introuvable"}) return } - remaining, err := database.ClaimPoolReward(username, req.PoolKey, reward.Threshold) + // Un produit récompense n'est éligible pour ce pool que si sa catégorie + // fait partie des catégories du pool (via CategoryConfigs) — sans ce + // filtre, un client pourrait réclamer n'importe quel produit récompense + // (toutes catégories confondues) avec les points d'un pool quelconque. + poolCategories := make(map[string]bool, len(selectedPool.Categories)) + for _, cat := range selectedPool.Categories { + poolCategories[cat] = true + } + + rewardProductIDs := make([]int, 0, len(reward.RewardItems)) + for _, item := range reward.RewardItems { + if item.ProductID > 0 { + rewardProductIDs = append(rewardProductIDs, item.ProductID) + } + } + productCategories, err := database.GetProductCategoriesByIDs(rewardProductIDs) + if err != nil { + utils.ServerErr(c, "Erreur lecture catégories produits", err) + return + } + + eligibleProductIDs := eligibleRewardProductIDs(reward, poolCategories, productCategories) + + eligibleItems := make([]models.RewardItem, 0, len(reward.RewardItems)) + for _, item := range reward.RewardItems { + if eligibleProductIDs[item.ProductID] { + eligibleItems = append(eligibleItems, item) + } + } + + itemsToAdd := eligibleItems + if req.ProductID > 0 { + itemsToAdd = nil + for _, item := range eligibleItems { + if item.ProductID == req.ProductID { + itemsToAdd = []models.RewardItem{item} + break + } + } + if itemsToAdd == nil { + c.JSON(http.StatusForbidden, gin.H{"error": "Ce produit n'est pas éligible pour cette récompense"}) + return + } + } + + remaining, added, err := database.ClaimPoolRewardAndAddToBasket(username, req.PoolKey, reward.Threshold, itemsToAdd) if err != nil { if strings.Contains(err.Error(), "pas de récompense disponible") { c.JSON(http.StatusConflict, gin.H{"error": "Pas assez de points pour réclamer une récompense"}) return } + if strings.Contains(err.Error(), "produit récompense introuvable") { + log.Printf("❌ [CLAIM] Configuration récompense invalide pour %s: %v", username, err) + c.JSON(http.StatusConflict, gin.H{"error": "Récompense momentanément indisponible, contactez le support"}) + return + } utils.ServerErr(c, "Erreur réclamation récompense", err) return } - // Si le client a sélectionné un produit spécifique parmi plusieurs, ne donner que celui-là - itemsToAdd := reward.RewardItems - if req.ProductID > 0 && len(reward.RewardItems) > 1 { - for _, item := range reward.RewardItems { - if item.ProductID == req.ProductID { - itemsToAdd = []models.RewardItem{item} - break - } - } - } - - // Ajouter les produits récompense au panier si configurés - productAdded := false + productAdded := len(added) > 0 var productNames []string - if len(itemsToAdd) > 0 { - if added, addErr := database.AddRewardsToBasket(username, itemsToAdd, req.PoolKey); addErr == nil && len(added) > 0 { - productAdded = true - for _, item := range added { - productNames = append(productNames, item.ProductName) - } - log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username) - } else if addErr != nil { - log.Printf("⚠️ [CLAIM] Impossible d'ajouter produits récompense: %v", addErr) - } + for _, item := range added { + productNames = append(productNames, item.ProductName) + } + if productAdded { + log.Printf("✅ [CLAIM] %d produit(s) récompense ajoutés au panier de %s", len(added), username) } c.JSON(http.StatusOK, gin.H{ @@ -252,7 +330,6 @@ func ClaimMyReward(c *gin.Context) { }) } -// AdminResetClientRedeemed remet à zéro les récompenses réclamées d'un client (admin). func AdminResetClientRedeemed(c *gin.Context) { username := c.Param("username") poolKey := c.Query("pool_key") diff --git a/backend/gestion/handlers/product.go b/backend/gestion/handlers/product.go index ab291227..8cd984e3 100644 --- a/backend/gestion/handlers/product.go +++ b/backend/gestion/handlers/product.go @@ -4,12 +4,12 @@ import ( "fmt" "gestion/db" "gestion/models" + "gestion/services" "gestion/utils" + "io" "log" "mime/multipart" "net/http" - "os" - "path/filepath" "strconv" "strings" @@ -116,7 +116,6 @@ func validateCategory(database *db.Database, category string) error { return nil } -// ✅ VÉRIFICATION DU TYPE MIME RÉEL (pas juste l'extension) func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { file, err := fileHeader.Open() if err != nil { @@ -130,7 +129,6 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { } mimeType := mtype.String() - // Normaliser : couper les paramètres éventuels (ex: "video/mp4; codecs=...") if idx := strings.Index(mimeType, ";"); idx != -1 { mimeType = strings.TrimSpace(mimeType[:idx]) } @@ -142,28 +140,9 @@ func validateFileMimeType(fileHeader *multipart.FileHeader) (string, error) { return mimeType, nil } -// ✅ PROTECTION CONTRE PATH TRAVERSAL -func sanitizeFilePath(path string) (string, error) { - // Nettoyer le chemin - cleaned := filepath.Clean(path) - - // Vérifier qu'il ne contient pas de ".." - if strings.Contains(cleaned, "..") { - return "", fmt.Errorf("path traversal détecté") - } - - // Vérifier qu'il commence par "uploads/" - if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") { - return "", fmt.Errorf("chemin invalide") - } - - return cleaned, nil -} - func CreateProduct(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ VÉRIFIER LE RÔLE (déjà fait par middleware, double-check) role := c.GetString("role") if role != "admin" && role != "cabine" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) @@ -172,14 +151,12 @@ func CreateProduct(c *gin.Context) { username, _ := safeGetUsername(c) - // ✅ PARSER AVEC LIMITE DE TAILLE if err := c.Request.ParseMultipartForm(MaxTotalUploadSize); err != nil { log.Printf("❌ [CreateProduct] Formulaire trop grand: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Fichiers trop volumineux"}) return } - // ✅ RÉCUPÉRER ET VALIDER LES DONNÉES name := strings.TrimSpace(c.PostForm("name")) category := strings.TrimSpace(c.PostForm("category")) description := strings.TrimSpace(c.PostForm("description")) @@ -189,7 +166,6 @@ func CreateProduct(c *gin.Context) { unit = "u" } - // ✅ VALIDATION STRICTE if err := validateProductName(name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -200,7 +176,6 @@ func CreateProduct(c *gin.Context) { return } - // ✅ NETTOYER ET VALIDER LA CATÉGORIE category = strings.ToLower(strings.TrimSpace(category)) category = strings.Map(func(r rune) rune { if r < 32 || r == 127 { @@ -219,7 +194,6 @@ func CreateProduct(c *gin.Context) { return } - // ✅ VALIDER LE STOCK stock, err := strconv.ParseFloat(stockStr, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Stock invalide"}) @@ -231,11 +205,10 @@ func CreateProduct(c *gin.Context) { return } - // ✅ RÉCUPÉRER ET VALIDER LES PRIX prices := []models.ProductPrice{} priceIndex := 0 - for priceIndex < 100 { // Limite anti-spam + for priceIndex < 100 { quantityKey := fmt.Sprintf("prices[%d][quantity]", priceIndex) priceKey := fmt.Sprintf("prices[%d][price]", priceIndex) activePriceKey := fmt.Sprintf("prices[%d][active_price]", priceIndex) @@ -323,7 +296,6 @@ func CreateProduct(c *gin.Context) { return } - // ✅ LIMITER LE NOMBRE DE FICHIERS if len(files) > MaxFilesPerProduct { database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{ @@ -336,13 +308,13 @@ func CreateProduct(c *gin.Context) { cleanProductName := cleanFileName(product.Name) uploadedMedia := []models.Media{} - savedFiles := []string{} + savedFiles := []models.Media{} + storage := c.MustGet("storage").(services.Storage) var totalSize int64 = 0 for i, fileHeader := range files { - // ✅ VÉRIFIER LA TAILLE INDIVIDUELLE if fileHeader.Size > MaxFileSize { - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Fichier %s trop volumineux (max %dMB)", fileHeader.Filename, MaxFileSize/(1024*1024)), @@ -351,10 +323,8 @@ func CreateProduct(c *gin.Context) { } totalSize += fileHeader.Size - - // ✅ VÉRIFIER LA TAILLE TOTALE if totalSize > MaxTotalUploadSize { - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Taille totale dépassée (max %dMB)", MaxTotalUploadSize/(1024*1024)), @@ -364,76 +334,50 @@ func CreateProduct(c *gin.Context) { log.Printf("📄 [%d/%d] Traitement: %s", i+1, len(files), fileHeader.Filename) - // ✅ VÉRIFIER LE TYPE MIME RÉEL (pas juste l'extension) mimeType, err := validateFileMimeType(fileHeader) if err != nil { log.Printf("❌ [CreateProduct] Type MIME invalide: %v", err) - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{"error": "Type de fichier non autorisé"}) return } - // ✅ DÉTERMINER LE TYPE DE MÉDIA var mediaType string if strings.HasPrefix(mimeType, "image/") { mediaType = "image" } else if strings.HasPrefix(mimeType, "video/") { mediaType = "video" } else { - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusBadRequest, gin.H{"error": "Type de média non supporté"}) return } - // ✅ GÉNÉRER UN NOM UNIQUE ET SÉCURISÉ uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, fileHeader.Filename) - // ✅ CRÉER LE DOSSIER DE MANIÈRE SÉCURISÉE - destFolder := filepath.Join("uploads", mediaType+"s") - if err := os.MkdirAll(destFolder, 0750); err != nil { - log.Printf("❌ [CreateProduct] Erreur création dossier: %v", err) - rollbackFiles(savedFiles) - database.DeleteProduct(product.ID) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur système"}) - return - } - - filePath := filepath.Join(destFolder, uniqueFileName) - - // ✅ VALIDER LE CHEMIN (protection path traversal) - safeFilePath, err := sanitizeFilePath(filePath) + mediaURL, mediaKey, err := storage.Upload(fileHeader, mediaType+"s", uniqueFileName) if err != nil { - log.Printf("❌ [CreateProduct] Path traversal détecté: %v", err) - rollbackFiles(savedFiles) - database.DeleteProduct(product.ID) - c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"}) - return - } - - // ✅ SAUVEGARDER LE FICHIER - if err := c.SaveUploadedFile(fileHeader, safeFilePath); err != nil { log.Printf("❌ [CreateProduct] Erreur sauvegarde: %v", err) - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"}) return } - savedFiles = append(savedFiles, safeFilePath) + savedFiles = append(savedFiles, models.Media{URL: mediaURL, Key: mediaKey}) - // ✅ CRÉER L'ENTRÉE MÉDIA - mediaURL := "/" + filepath.ToSlash(safeFilePath) media := models.Media{ ProductID: product.ID, Type: mediaType, URL: mediaURL, + Key: mediaKey, } if err := database.CreateMedia(&media); err != nil { log.Printf("❌ [CreateProduct] Erreur DB média: %v", err) - rollbackFiles(savedFiles) + rollbackFiles(storage, savedFiles) database.DeleteProduct(product.ID) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"}) return @@ -480,7 +424,6 @@ func GetProductsByCategory(c *gin.Context) { category := strings.ToLower(strings.TrimSpace(c.Param("category"))) - // ✅ VALIDATION if err := validateCategory(database, category); err != nil { c.JSON(http.StatusBadRequest, gin.H{ "success": false, @@ -499,7 +442,6 @@ func GetProductsByCategory(c *gin.Context) { return } - // ✅ Charger les médias for i := range products { media, _ := database.GetMediaByProductID(products[i].ID) products[i].Media = media @@ -533,11 +475,9 @@ func GetProductByID(c *gin.Context) { }) return } - // ✅ Charger les médias media, _ := database.GetMediaByProductID(product.ID) product.Media = media - // ✅ Filtrer les prix désactivés (sauf pour admin/cabine) role := c.GetString("role") if role != "admin" && role != "cabine" { filterActivepricesSingle(&product) @@ -552,7 +492,6 @@ func GetProductByID(c *gin.Context) { func UpdateProduct(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ VÉRIFIER LE RÔLE role := c.GetString("role") if role != "admin" && role != "cabine" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) @@ -567,7 +506,6 @@ func UpdateProduct(c *gin.Context) { return } - // ✅ VÉRIFIER QUE LE PRODUIT EXISTE _, err = database.GetProductByID(id) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) @@ -589,7 +527,6 @@ func UpdateProduct(c *gin.Context) { return } - // ✅ VALIDATION COMPLÈTE if err := validateProductName(updateData.Name); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return @@ -733,8 +670,8 @@ func UpdateStock(c *gin.Context) { func DeleteMedia(c *gin.Context) { database := c.MustGet("database").(*db.Database) + s3Service := c.MustGet("s3Service").(*services.S3Service) - // ✅ VÉRIFIER LE RÔLE role := c.GetString("role") if role != "admin" && role != "cabine" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) @@ -753,28 +690,27 @@ func DeleteMedia(c *gin.Context) { return } - // ✅ SÉCURISER LE CHEMIN AVANT SUPPRESSION - filePath := strings.TrimPrefix(media.URL, "/") - - safeFilePath, err := sanitizeFilePath(filePath) - if err != nil { - log.Printf("❌ [DeleteMedia] Path invalide: %v", err) - c.JSON(http.StatusBadRequest, gin.H{"error": "Chemin invalide"}) - return - } - - // ✅ SUPPRIMER LE FICHIER PHYSIQUE - if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) { - log.Printf("⚠️ [DeleteMedia] Erreur suppression fichier: %v", err) - } - - // ✅ SUPPRIMER DE LA DB - err = database.DeleteMedia(mediaID) - if err != nil { + if err := database.DeleteMedia(mediaID); err != nil { + log.Printf("❌ [DeleteMedia] Erreur suppression DB: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression"}) return } + if media.Key != "" { + if err := s3Service.DeleteFile(media.Key); err != nil { + log.Printf("⚠️ [DeleteMedia] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err) + } else { + log.Printf("✅ [DeleteMedia] Fichier supprimé sur RustFS: %s", media.Key) + } + } else { + localStorage := services.NewLocalStorage("uploads") + if err := localStorage.Delete(media.URL, ""); err != nil { + log.Printf("⚠️ [DeleteMedia] Fichier local non supprimé (%s): %v", media.URL, err) + } else { + log.Printf("✅ [DeleteMedia] Fichier local supprimé: %s", media.URL) + } + } + c.JSON(http.StatusOK, gin.H{ "success": true, "message": "Média supprimé", @@ -784,7 +720,6 @@ func DeleteMedia(c *gin.Context) { func UploadMedia(c *gin.Context) { database := c.MustGet("database").(*db.Database) - // ✅ VÉRIFIER LE RÔLE username, err := safeGetUsername(c) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"}) @@ -792,19 +727,17 @@ func UploadMedia(c *gin.Context) { } role := c.GetString("role") - if role != "admin" && role != "cabine" { + if role != "admin" { c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) return } - // ✅ RÉCUPÉRER ET VALIDER L'ID PRODUIT productID, err := strconv.Atoi(c.Param("id")) if err != nil || productID <= 0 { c.JSON(http.StatusBadRequest, gin.H{"error": "ID produit invalide"}) return } - // ✅ VÉRIFIER QUE LE PRODUIT EXISTE productName, err := database.GetProductNameByID(productID) if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "Produit non trouvé"}) @@ -813,7 +746,6 @@ func UploadMedia(c *gin.Context) { log.Printf("📤 [UploadMedia] %s upload média pour produit #%d (%s)", username, productID, productName) - // ✅ RÉCUPÉRER LE TYPE ET LE FICHIER fileType := c.PostForm("type") if fileType != "image" && fileType != "video" { c.JSON(http.StatusBadRequest, gin.H{"error": "Type invalide (image ou video requis)"}) @@ -827,7 +759,6 @@ func UploadMedia(c *gin.Context) { return } - // ✅ VÉRIFIER LA TAILLE const MaxFileSize = 10 * 1024 * 1024 // 10MB if file.Size > MaxFileSize { c.JSON(http.StatusBadRequest, gin.H{ @@ -836,7 +767,6 @@ func UploadMedia(c *gin.Context) { return } - // ✅ VÉRIFIER LE TYPE MIME RÉEL detectedMime, err := validateFileMimeType(file) if err != nil { log.Printf("❌ [UploadMedia] Type MIME invalide: %v", err) @@ -846,7 +776,6 @@ func UploadMedia(c *gin.Context) { log.Printf("📋 [UploadMedia] Type MIME détecté: %s", detectedMime) - // Vérifier que le MIME correspond au type déclaré if fileType == "image" && !strings.HasPrefix(detectedMime, "image/") { c.JSON(http.StatusBadRequest, gin.H{"error": "Le fichier n'est pas une image valide"}) return @@ -856,40 +785,32 @@ func UploadMedia(c *gin.Context) { return } - // ✅ GÉNÉRER UN NOM UNIQUE cleanProductName := cleanFileName(productName) uniqueFileName := utils.GenerateUniqueFileName(cleanProductName, file.Filename) - // ✅ CRÉER LE DOSSIER - destFolder := filepath.Join("uploads", fileType+"s") - if err := os.MkdirAll(destFolder, 0750); err != nil { - log.Printf("❌ [UploadMedia] Erreur création dossier: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création dossier"}) + storage := c.MustGet("storage").(services.Storage) + folder := fileType + "s" + mediaURL, mediaKey, err := storage.Upload(file, folder, uniqueFileName) + if err != nil { + log.Printf("❌ [UploadMedia] Erreur upload: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur upload fichier"}) return } - // ✅ SAUVEGARDER LE FICHIER - filePath := filepath.Join(destFolder, uniqueFileName) - if err := c.SaveUploadedFile(file, filePath); err != nil { - log.Printf("❌ [UploadMedia] Erreur sauvegarde: %v", err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur sauvegarde fichier"}) - return - } + log.Printf("✅ [UploadMedia] Fichier uploadé: %s", mediaURL) - log.Printf("✅ [UploadMedia] Fichier sauvegardé: %s", filePath) - - // ✅ CRÉER L'ENTRÉE EN BASE - mediaURL := "/" + filepath.ToSlash(filePath) media := models.Media{ ProductID: productID, Type: fileType, URL: mediaURL, + Key: mediaKey, } err = database.CreateMedia(&media) if err != nil { - // Rollback: supprimer le fichier - os.Remove(filePath) + if delErr := storage.Delete(mediaURL, mediaKey); delErr != nil { + log.Printf("⚠️ [UploadMedia] Échec rollback (%s): %v", mediaURL, delErr) + } log.Printf("❌ [UploadMedia] Erreur DB: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur création média"}) return @@ -908,6 +829,28 @@ func UploadMedia(c *gin.Context) { }) } +func ServeMedia(c *gin.Context) { + s3Service := c.MustGet("s3Service").(*services.S3Service) + + key := strings.TrimPrefix(c.Param("key"), "/") + if key == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Clé manquante"}) + return + } + + body, contentType, err := s3Service.GetFile(c.Request.Context(), key) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Média non trouvé"}) + return + } + defer body.Close() + + c.Header("Content-Type", contentType) + c.Header("Cache-Control", "public, max-age=31536000, immutable") + c.Status(http.StatusOK) + io.Copy(c.Writer, body) +} + func ActivePrice(c *gin.Context) { database := c.MustGet("database").(*db.Database) role := c.GetString("role") @@ -952,10 +895,6 @@ func DesActivePrice(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Prix désactivé avec succès"}) } -// ============================================ -// DELETE PRODUCT - VERSION SÉCURISÉE -// ============================================ - func DeleteProduct(c *gin.Context) { database := c.MustGet("database").(*db.Database) @@ -976,32 +915,28 @@ func DeleteProduct(c *gin.Context) { log.Printf("🗑️ [DeleteProduct] %s supprime produit #%d", username, id) - // ✅ RÉCUPÉRER LES MÉDIAS AVANT SUPPRESSION mediaList, err := database.GetMediaByProductID(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération médias"}) return } - // ✅ SUPPRIMER LES FICHIERS AVEC SÉCURITÉ + s3Service := c.MustGet("s3Service").(*services.S3Service) + localStorage := services.NewLocalStorage("uploads") for _, media := range mediaList { - filePath := strings.TrimPrefix(media.URL, "/") - - safeFilePath, err := sanitizeFilePath(filePath) - if err != nil { - log.Printf("⚠️ [DeleteProduct] Path invalide: %v", err) - continue - } - - if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) { - log.Printf("⚠️ [DeleteProduct] Erreur suppression: %v", err) + if media.Key != "" { + if err := s3Service.DeleteFile(media.Key); err != nil { + log.Printf("⚠️ [DeleteProduct] Fichier non supprimé sur RustFS (clé: %s): %v", media.Key, err) + } + } else { + if err := localStorage.Delete(media.URL, ""); err != nil { + log.Printf("⚠️ [DeleteProduct] Erreur suppression locale: %v", err) + } } } - // ✅ SUPPRIMER LES MÉDIAS DE LA DB database.DeleteMediaByProductID(id) - // ✅ SUPPRIMER LE PRODUIT err = database.DeleteProduct(id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression produit"}) @@ -1016,17 +951,11 @@ func DeleteProduct(c *gin.Context) { }) } -// ============================================ -// HELPERS -// ============================================ - -func rollbackFiles(files []string) { - for _, file := range files { - safeFilePath, err := sanitizeFilePath(file) - if err != nil { - continue +func rollbackFiles(storage services.Storage, files []models.Media) { + for _, f := range files { + if err := storage.Delete(f.URL, f.Key); err != nil { + log.Printf("⚠️ [rollbackFiles] Erreur suppression %s: %v", f.URL, err) } - os.Remove(safeFilePath) } } diff --git a/backend/gestion/handlers/rating.go b/backend/gestion/handlers/rating.go new file mode 100644 index 00000000..8c4086a6 --- /dev/null +++ b/backend/gestion/handlers/rating.go @@ -0,0 +1,140 @@ +package handlers + +import ( + "gestion/db" + "gestion/utils" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +func SubmitLivreurRating(c *gin.Context) { + clientUsername := c.GetString("username") + if clientUsername == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + orderID, err := strconv.Atoi(c.Param("id")) + if err != nil || orderID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID commande invalide"}) + return + } + + var req struct { + Rating int `json:"rating" binding:"required,min=1,max=5"` + Comment string `json:"comment"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Note invalide (1 à 5 requis)"}) + return + } + + database := c.MustGet("database").(*db.Database) + + ownerUsername, livreurUsername, err := database.GetOrderForRating(orderID) + if err != nil { + utils.ServerErr(c, "Erreur lecture commande", err) + return + } + if ownerUsername == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "Commande introuvable ou non terminée"}) + return + } + if ownerUsername != clientUsername { + c.JSON(http.StatusForbidden, gin.H{"error": "Commande non autorisée"}) + return + } + if livreurUsername == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Aucun livreur assigné à cette commande"}) + return + } + + existing, err := database.GetOrderRating(orderID) + if err != nil { + utils.ServerErr(c, "Erreur vérification avis", err) + return + } + if existing != nil { + c.JSON(http.StatusConflict, gin.H{"error": "Vous avez déjà noté ce livreur pour cette commande"}) + return + } + + if err := database.SubmitLivreurRating(orderID, livreurUsername, clientUsername, req.Rating, req.Comment); err != nil { + utils.ServerErr(c, "Erreur enregistrement avis", err) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +func GetLivreurRatings(c *gin.Context) { + username := c.Param("username") + if username == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Username requis"}) + return + } + + database := c.MustGet("database").(*db.Database) + ratings, avg, err := database.GetLivreurRatings(username) + if err != nil { + utils.ServerErr(c, "Erreur récupération avis", err) + return + } + + c.JSON(http.StatusOK, gin.H{ + "ratings": ratings, + "average": avg, + "count": len(ratings), + }) +} + +// GetMyRatings retourne les avis reçus par le livreur connecté (uniquement les siens). +func GetMyRatings(c *gin.Context) { + username := c.GetString("username") + if username == "" || c.GetString("role") != "livreur" { + c.JSON(http.StatusForbidden, gin.H{"error": "Accès réservé aux livreurs"}) + return + } + + database := c.MustGet("database").(*db.Database) + ratings, avg, err := database.GetLivreurRatings(username) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération avis"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "ratings": ratings, + "average": avg, + "count": len(ratings), + }) +} + +func GetOrderRatingStatus(c *gin.Context) { + clientUsername := c.GetString("username") + if clientUsername == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"}) + return + } + + orderID, err := strconv.Atoi(c.Param("id")) + if err != nil || orderID <= 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID invalide"}) + return + } + + database := c.MustGet("database").(*db.Database) + rating, err := database.GetOrderRating(orderID) + if err != nil { + utils.ServerErr(c, "Erreur", err) + return + } + + if rating == nil { + c.JSON(http.StatusOK, gin.H{"rated": false}) + return + } + c.JSON(http.StatusOK, gin.H{"rated": true, "rating": rating.Rating, "comment": rating.Comment}) +} diff --git a/backend/gestion/handlers/redis_services.go b/backend/gestion/handlers/redis_services.go index 6ded3e41..5cdcd949 100644 --- a/backend/gestion/handlers/redis_services.go +++ b/backend/gestion/handlers/redis_services.go @@ -307,19 +307,22 @@ func GetDeliverymanLocationForCommand(c *gin.Context) { } // ✅ 6. Récupérer l'ETA de la commande depuis Redis (si disponible) + // La clé est un hash (HSet), jamais une simple valeur — Redis.Get renvoie + // une erreur WRONGTYPE dessus, silencieusement ignorée ici auparavant, + // ce qui faisait toujours renvoyer etaMinutes=0. etaKey := fmt.Sprintf("command:eta:%d", commandID) - etaData, _ := db.Redis.Get(db.RedisCtx, etaKey).Result() + eta, _ := db.Redis.HGetAll(db.RedisCtx, etaKey).Result() var etaMinutes int = 0 var etaSetAt int64 = 0 - if etaData != "" { - var eta map[string]interface{} - json.Unmarshal([]byte(etaData), &eta) - if minutes, ok := eta["minutes"].(float64); ok { - etaMinutes = int(minutes) + if minutesStr, ok := eta["eta_minutes"]; ok { + if minutes, err := strconv.Atoi(minutesStr); err == nil { + etaMinutes = minutes } - if timestamp, ok := eta["set_at"].(float64); ok { - etaSetAt = int64(timestamp) + } + if updatedAtStr, ok := eta["updated_at"]; ok { + if timestamp, err := strconv.ParseInt(updatedAtStr, 10, 64); err == nil { + etaSetAt = timestamp } } @@ -947,36 +950,6 @@ func SubtractClientPointsAdmin(c *gin.Context) { }) } -func GetRealtimeStats(c *gin.Context) { - userRole := c.GetString("role") - if userRole != "admin" { - c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"}) - return - } - - stats, err := db.Redis.HGetAll(db.RedisCtx, "stats:realtime").Result() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{ - "error": "Erreur lors de la récupération des statistiques", - }) - return - } - - if len(stats) == 0 { - c.JSON(http.StatusOK, gin.H{ - "success": true, - "message": "Aucune statistique disponible pour le moment", - "stats": map[string]interface{}{}, - }) - return - } - - c.JSON(http.StatusOK, gin.H{ - "success": true, - "stats": stats, - }) -} - func refreshETAForActivDelivery(username string, lat, lon float64) { // 1. Récupérer le statut actuel du livreur statusKey := fmt.Sprintf("delivery:status:%s", username) @@ -1047,12 +1020,17 @@ func refreshETAForActivDelivery(username string, lat, lon float64) { 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, + "command_id": commandID, + // eta_minutes ET total_eta_minutes doivent tous les deux être présents + // (voir le commentaire de SetCommandETAWithDetails) — sans quoi les + // lecteurs qui attendent l'un ou l'autre nom de champ ne trouvent rien. + "eta_minutes": etaMinutes, + "total_eta_minutes": etaMinutes, + "updated_at": now.Unix(), + "arrival_time": arrivalTime.Unix(), + "estimated_arrival": arrivalTime.Format(time.RFC3339), + "distance_km": distanceKm, + "with_traffic": err == nil, }) db.Redis.Expire(db.RedisCtx, etaKey, 4*time.Hour) } diff --git a/backend/gestion/handlers/settings.go b/backend/gestion/handlers/settings.go index 3482d2cb..cb6a5aa1 100644 --- a/backend/gestion/handlers/settings.go +++ b/backend/gestion/handlers/settings.go @@ -48,6 +48,13 @@ func GetPublicSettings(c *gin.Context) { "shop_name": settings.ShopName, "two_fa_enabled": settings.Telegram2FAEnabled, "contact_telegram": settings.ContactTelegram, + "client_color_primary": settings.ClientColorPrimary, + "client_color_secondary": settings.ClientColorSecondary, + "client_color_success": settings.ClientColorSuccess, + "client_color_danger": settings.ClientColorDanger, + "client_color_warning": settings.ClientColorWarning, + "client_title_gradient_from": settings.ClientTitleGradientFrom, + "client_title_gradient_to": settings.ClientTitleGradientTo, }) } diff --git a/backend/gestion/handlers/stats.go b/backend/gestion/handlers/stats.go index 65c8b83b..cbfe6269 100644 --- a/backend/gestion/handlers/stats.go +++ b/backend/gestion/handlers/stats.go @@ -1,38 +1,270 @@ package handlers import ( + "context" "fmt" "gestion/db" "gestion/models" "net/http" + "time" "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" ) var weekdayNames = []string{"Dimanche", "Lundi", "Mardi", "Mercredi", "Jeudi", "Vendredi", "Samedi"} +// sections valides pour le reset +var validStatsSections = map[string]string{ + "commandes": "stats_reset_commandes_at", + "revenus": "stats_reset_revenus_at", + "produits": "stats_reset_produits_at", + "heures": "stats_reset_heures_at", + "jours": "stats_reset_jours_at", + "doses": "stats_reset_doses_at", +} + +// ResetAdminStats réinitialise une section précise des statistiques. +func ResetAdminStats(c *gin.Context) { + section := c.Param("section") + key, ok := validStatsSections[section] + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)}) + return + } + + database := c.MustGet("database").(*db.Database) + now := time.Now().UTC().Format(time.RFC3339) + + if err := database.ResetAdminStat(key); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now}) +} + +func dateFilter(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre +// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de +// commandes, le revenu et la quantité vendue. Les jours sans commande sont +// inclus avec des valeurs à zéro. +func GetAdminStatsByMonth(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + monthParam := c.Query("month") + monthStart := time.Now() + if monthParam != "" { + parsed, err := time.Parse("2006-01", monthParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)}) + return + } + monthStart = parsed + } + monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) + + resetCmd := database.ReadResetAt("stats_reset_commandes_at") + + var rows []db.DailyMonthStatRow + if err := database.StatsByDayForMonth(&rows, monthStart, resetCmd); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)}) + return + } + + rowByDay := make(map[string]db.DailyMonthStatRow, len(rows)) + for _, r := range rows { + rowByDay[r.Day.Format("2006-01-02")] = r + } + + daysInMonth := monthStart.AddDate(0, 1, -1).Day() + byDay := make([]gin.H, daysInMonth) + var totalOrders int + var totalRevenue float64 + var totalQuantity float64 + + for i := range daysInMonth { + day := monthStart.AddDate(0, 0, i) + key := day.Format("2006-01-02") + r, ok := rowByDay[key] + if !ok { + r = db.DailyMonthStatRow{Day: day} + } + byDay[i] = gin.H{ + "day": key, + "label": day.Format("02/01"), + "count": r.Count, + "revenue": r.Revenue, + "quantity": r.Quantity, + } + totalOrders += r.Count + totalRevenue += r.Revenue + totalQuantity += r.Quantity + } + + c.JSON(http.StatusOK, gin.H{ + "month": monthStart.Format("2006-01"), + "summary": gin.H{ + "total_orders": totalOrders, + "total_revenue": totalRevenue, + "total_quantity": totalQuantity, + }, + "by_day": byDay, + }) +} + +func GetAdminDailyDetail(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + dateParam := c.Query("date") + date := time.Now() + if dateParam != "" { + parsed, err := time.Parse("2006-01-02", dateParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre date invalide (attendu YYYY-MM-DD) : %s", dateParam)}) + return + } + date = parsed + } + + var dailyRows []models.DailyProductRow + if err := database.DailyProductDetailForDate(&dailyRows, date); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération du détail du jour: %s", err)}) + return + } + + type dailyCatGroup struct { + Category string + CategoryColor string + TotalQuantity float64 + TotalRevenue float64 + Products []gin.H + } + var dailyCats []dailyCatGroup + dailyCatIdx := map[string]int{} + dailyTotalRevenue := 0.0 + dailyTotalQty := 0.0 + + for _, r := range dailyRows { + dailyTotalRevenue += r.Revenue + dailyTotalQty += r.TotalQuantity + idx, ok := dailyCatIdx[r.Category] + if !ok { + idx = len(dailyCats) + dailyCats = append(dailyCats, dailyCatGroup{ + Category: r.Category, + CategoryColor: r.CategoryColor, + }) + dailyCatIdx[r.Category] = idx + } + dailyCats[idx].TotalQuantity += r.TotalQuantity + dailyCats[idx].TotalRevenue += r.Revenue + dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{ + "product_id": r.ProductID, + "name": r.ProductName, + "quantity": r.TotalQuantity, + "order_count": r.OrderCount, + "revenue": r.Revenue, + }) + } + + dailyCatsJSON := make([]gin.H, len(dailyCats)) + for i, g := range dailyCats { + dailyCatsJSON[i] = gin.H{ + "category": g.Category, + "category_color": g.CategoryColor, + "total_quantity": g.TotalQuantity, + "total_revenue": g.TotalRevenue, + "products": g.Products, + } + } + + dailyTotalOrders, _ := database.DailyOrdersCountForDate(date) + + c.JSON(http.StatusOK, gin.H{ + "date": date.Format("02/01/2006"), + "total_orders": dailyTotalOrders, + "total_quantity": dailyTotalQty, + "total_revenue": dailyTotalRevenue, + "categories": dailyCatsJSON, + }) +} + // GetAdminStats returns aggregated order & product statistics for the admin dashboard. func GetAdminStats(c *gin.Context) { database := c.MustGet("database").(*db.Database) - gdb := database.GDB - // ── Commandes par jour de la semaine (all time, non annulées) ────────────── - var wdRows []models.WeekdayRow - gdb.Raw(` - SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count - FROM commandes - WHERE status != 'cancelled' - GROUP BY dow - ORDER BY dow - `).Scan(&wdRows) + filters := database.LoadAdminStatsFilters() + // Toutes les requêtes sont indépendantes — on les lance en parallèle. + var ( + wdRows []models.WeekdayRow + dayRows []models.DayRow + dayRevRows []models.DayRevenueRow + hourRows []models.HourRow + prodRows []models.ProductRow + qtyRows []models.QuantityBreakdownRow + dailyRows []models.DailyProductRow + totalOrders int64 + totalRevenue float64 + dailyTotalOrders int64 + activeDays int64 + last30Count int64 + ) + + eg, _ := errgroup.WithContext(context.Background()) + eg.Go(func() error { return database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) }) + eg.Go(func() error { return database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) }) + eg.Go(func() error { return database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) }) + eg.Go(func() error { return database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) }) + eg.Go(func() error { return database.TopProducts(&prodRows, filters.ResetProduits, 15) }) + eg.Go(func() error { return database.QuantityBreakdown(&qtyRows, filters.ResetDoses) }) + eg.Go(func() error { return database.DailyProductDetail(&dailyRows) }) + eg.Go(func() error { + var err error + totalOrders, err = database.TotalOrders(filters.ResetCommandes) + return err + }) + eg.Go(func() error { + var err error + totalRevenue, err = database.TotalRevenue(filters.ResetRevenus) + return err + }) + eg.Go(func() error { + var err error + dailyTotalOrders, err = database.DailyOrdersCount() + return err + }) + eg.Go(func() error { + var err error + activeDays, err = database.ActiveDaysLast30(filters.ResetCommandes) + return err + }) + eg.Go(func() error { + var err error + last30Count, err = database.OrdersCountLast30(filters.ResetCommandes) + return err + }) + + if err := eg.Wait(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des statistiques"}) + return + } + + // ── Commandes par jour de la semaine ────────────────────────────────────── byWeekday := make([]gin.H, 7) wdMap := make(map[int]int, len(wdRows)) for _, r := range wdRows { wdMap[r.DOW] = r.Count } peakCount, peakWeekday := 0, "" - for i := 0; i < 7; i++ { + for i := range 7 { cnt := wdMap[i] byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt} if cnt > peakCount { @@ -42,16 +274,6 @@ func GetAdminStats(c *gin.Context) { } // ── Commandes par jour sur 30 jours ─────────────────────────────────────── - var dayRows []models.DayRow - gdb.Raw(` - SELECT DATE(created_at) AS day, COUNT(*) AS count - FROM commandes - WHERE created_at >= NOW() - INTERVAL '30 days' - AND status != 'cancelled' - GROUP BY DATE(created_at) - ORDER BY day - `).Scan(&dayRows) - byDay := make([]gin.H, len(dayRows)) for i, r := range dayRows { byDay[i] = gin.H{ @@ -61,17 +283,7 @@ func GetAdminStats(c *gin.Context) { } } - // ── Revenus par jour sur 30 jours (commandes approuvées) ───────────────── - var dayRevRows []models.DayRevenueRow - gdb.Raw(` - SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE created_at >= NOW() - INTERVAL '30 days' - AND status = 'approved' - GROUP BY DATE(created_at) - ORDER BY day - `).Scan(&dayRevRows) - + // ── Revenus par jour sur 30 jours ───────────────────────────────────────── byDayRevenue := make([]gin.H, len(dayRevRows)) for i, r := range dayRevRows { byDayRevenue[i] = gin.H{ @@ -81,25 +293,13 @@ func GetAdminStats(c *gin.Context) { } } - // ── Commandes & revenus par heure (all time, non annulées) ─────────────── - var hourRows []models.HourRow - gdb.Raw(` - SELECT - EXTRACT(HOUR FROM created_at)::int AS hour, - COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE status != 'cancelled' - GROUP BY hour - ORDER BY hour - `).Scan(&hourRows) - + // ── Commandes & revenus par heure ───────────────────────────────────────── hourMap := make(map[int]models.HourRow, len(hourRows)) for _, r := range hourRows { hourMap[r.Hour] = r } byHour := make([]gin.H, 24) - for h := 0; h < 24; h++ { + for h := range 24 { r := hourMap[h] byHour[h] = gin.H{ "hour": h, @@ -109,27 +309,7 @@ func GetAdminStats(c *gin.Context) { } } - // ── Top produits (quantité vendue, commandes terminées) ─────────────────── - var prodRows []models.ProductRow - gdb.Raw(` - SELECT - ci.product_id, - ci.produit AS name, - SUM(ci.quantite) AS total_quantity, - COUNT(DISTINCT ci.command_id) AS order_count, - SUM(ci.prix) AS revenue, - COALESCE(p.category, '') AS category, - COALESCE(cat.color, '#7c3aed') AS category_color - FROM command_items ci - JOIN commandes c ON c.id = ci.command_id - LEFT JOIN products p ON p.id = ci.product_id - LEFT JOIN categories cat ON cat.name = p.category - WHERE c.status != 'cancelled' - GROUP BY ci.product_id, ci.produit, p.category, cat.color - ORDER BY total_quantity DESC - LIMIT 15 - `).Scan(&prodRows) - + // ── Top produits ────────────────────────────────────────────────────────── topProducts := make([]gin.H, len(prodRows)) topProductName := "" for i, r := range prodRows { @@ -147,26 +327,7 @@ func GetAdminStats(c *gin.Context) { } } - // ── Répartition des doses/quantités par produit ─────────────────────────── - var qtyRows []models.QuantityBreakdownRow - gdb.Raw(` - SELECT - ci.product_id, - ci.produit AS product_name, - ci.quantite AS quantity, - COUNT(DISTINCT ci.command_id) AS order_count, - SUM(ci.quantite) AS total_sold, - SUM(ci.prix) AS revenue, - COALESCE(cat.color, '#7c3aed') AS category_color - FROM command_items ci - JOIN commandes c ON c.id = ci.command_id - LEFT JOIN products p ON p.id = ci.product_id - LEFT JOIN categories cat ON cat.name = p.category - WHERE c.status != 'cancelled' - GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color - ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC - `).Scan(&qtyRows) - + // ── Répartition des doses/quantités ─────────────────────────────────────── type productGroup struct { ProductID int Name string @@ -195,7 +356,6 @@ func GetAdminStats(c *gin.Context) { "revenue": r.Revenue, }) } - // Trier par total de commandes décroissant, garder 15 max for i := 0; i < len(groups)-1; i++ { for j := i + 1; j < len(groups); j++ { if groups[j].TotalOrders > groups[i].TotalOrders { @@ -207,39 +367,65 @@ func GetAdminStats(c *gin.Context) { groups = groups[:15] } byQuantity := make([]gin.H, len(groups)) - for i, g := range groups { + for i, grp := range groups { byQuantity[i] = gin.H{ - "product_id": g.ProductID, - "name": g.Name, - "category_color": g.CategoryColor, - "total_orders": g.TotalOrders, - "quantities": g.Quantities, + "product_id": grp.ProductID, + "name": grp.Name, + "category_color": grp.CategoryColor, + "total_orders": grp.TotalOrders, + "quantities": grp.Quantities, + } + } + + // ── Détail du jour ──────────────────────────────────────────────────────── + type dailyCatGroup struct { + Category string + CategoryColor string + TotalQuantity float64 + TotalRevenue float64 + Products []gin.H + } + var dailyCats []dailyCatGroup + dailyCatIdx := map[string]int{} + dailyTotalRevenue := 0.0 + dailyTotalQty := 0.0 + for _, r := range dailyRows { + dailyTotalRevenue += r.Revenue + dailyTotalQty += r.TotalQuantity + idx, ok := dailyCatIdx[r.Category] + if !ok { + idx = len(dailyCats) + dailyCats = append(dailyCats, dailyCatGroup{ + Category: r.Category, + CategoryColor: r.CategoryColor, + }) + dailyCatIdx[r.Category] = idx + } + dailyCats[idx].TotalQuantity += r.TotalQuantity + dailyCats[idx].TotalRevenue += r.Revenue + dailyCats[idx].Products = append(dailyCats[idx].Products, gin.H{ + "product_id": r.ProductID, + "name": r.ProductName, + "quantity": r.TotalQuantity, + "order_count": r.OrderCount, + "revenue": r.Revenue, + }) + } + dailyCatsJSON := make([]gin.H, len(dailyCats)) + for i, grp := range dailyCats { + dailyCatsJSON[i] = gin.H{ + "category": grp.Category, + "category_color": grp.CategoryColor, + "total_quantity": grp.TotalQuantity, + "total_revenue": grp.TotalRevenue, + "products": grp.Products, } } // ── Résumé global ───────────────────────────────────────────────────────── - var totalOrders int64 - var totalRevenue float64 - gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE status != 'cancelled'`).Scan(&totalOrders) - gdb.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE status = 'approved'`).Scan(&totalRevenue) - avgPerDay := 0.0 - if totalOrders > 0 { - // average over the last 30 days with data - var activeDays int64 - gdb.Raw(` - SELECT COUNT(DISTINCT DATE(created_at)) - FROM commandes - WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled' - `).Scan(&activeDays) - if activeDays > 0 { - var last30Count int64 - gdb.Raw(` - SELECT COUNT(*) FROM commandes - WHERE created_at >= NOW() - INTERVAL '30 days' AND status != 'cancelled' - `).Scan(&last30Count) - avgPerDay = float64(last30Count) / float64(activeDays) - } + if totalOrders > 0 && activeDays > 0 { + avgPerDay = float64(last30Count) / float64(activeDays) } c.JSON(http.StatusOK, gin.H{ @@ -250,11 +436,24 @@ func GetAdminStats(c *gin.Context) { "top_product": topProductName, "avg_per_day": avgPerDay, }, - "by_weekday": byWeekday, - "by_day_30": byDay, - "by_day_revenue": byDayRevenue, - "by_hour": byHour, - "top_products": topProducts, - "by_quantity": byQuantity, + "reset_at_commandes": dateFilter(filters.ResetCommandes), + "reset_at_revenus": dateFilter(filters.ResetRevenus), + "reset_at_produits": dateFilter(filters.ResetProduits), + "reset_at_heures": dateFilter(filters.ResetHeures), + "reset_at_jours": dateFilter(filters.ResetJours), + "reset_at_doses": dateFilter(filters.ResetDoses), + "by_weekday": byWeekday, + "by_day_30": byDay, + "by_day_revenue": byDayRevenue, + "by_hour": byHour, + "top_products": topProducts, + "by_quantity": byQuantity, + "daily_detail": gin.H{ + "date": time.Now().Format("02/01/2006"), + "total_orders": dailyTotalOrders, + "total_quantity": dailyTotalQty, + "total_revenue": dailyTotalRevenue, + "categories": dailyCatsJSON, + }, }) } diff --git a/backend/gestion/handlers/update_profile.go b/backend/gestion/handlers/update_profile.go index 337b6fba..f91eab91 100644 --- a/backend/gestion/handlers/update_profile.go +++ b/backend/gestion/handlers/update_profile.go @@ -198,9 +198,6 @@ func UpdateClientByAdmin(c *gin.Context) { return } - // ✅ LOG DEBUG - Voir ce qui est reçu - log.Printf("📝 [UPDATE_CLIENT_ADMIN] Requête reçue: %+v", req) - // Récupérer le client actuel client, err := database.GetClientByID(clientID) if err != nil { diff --git a/backend/gestion/main.go b/backend/gestion/main.go index 1c5c65b5..86b5ef73 100644 --- a/backend/gestion/main.go +++ b/backend/gestion/main.go @@ -92,6 +92,29 @@ func main() { }() } + s3Service, err := services.NewS3Service( + os.Getenv("S3_REGION"), + os.Getenv("S3_BUCKET"), + os.Getenv("S3_ENDPOINT"), + services.S3Credentials{ + S3KeyId: os.Getenv("RUSTFS_ACCESS_KEY"), + S3AccessKey: os.Getenv("RUSTFS_SECRET_KEY"), + }, + ) + if err != nil { + log.Fatalf("erreur init S3: %v", err) + } + + var storage services.Storage + switch os.Getenv("STORAGE_DRIVER") { + case "s3": + storage = services.NewS3Storage(s3Service) + log.Println("✅ Storage driver: s3 (RustFS)") + default: + storage = services.NewLocalStorage("uploads") + log.Println("✅ Storage driver: local") + } + log.Println("") log.Println("🧹 Démarrage du nettoyage des commandes invalides...") removed, err := database.CleanupInvalidQueueCommands() @@ -153,6 +176,8 @@ func main() { r.Use(func(c *gin.Context) { c.Set("database", database) c.Set("geoService", geoService) + c.Set("s3Service", s3Service) + c.Set("storage", storage) c.Next() }) @@ -167,7 +192,7 @@ func main() { r.Static("/uploads", "./uploads") - routes.SetupRoutes(r, database, geoService) + routes.SetupRoutes(r, database, geoService, s3Service) if err := r.Run(":8080"); err != nil { log.Fatalf("❌ Erreur au lancement du serveur : %v", err) diff --git a/backend/gestion/models/media.go b/backend/gestion/models/media.go index 9dfc41dd..10b715b9 100644 --- a/backend/gestion/models/media.go +++ b/backend/gestion/models/media.go @@ -3,11 +3,12 @@ package models import "time" type Media struct { - ID int `gorm:"primaryKey;autoIncrement" json:"id"` - ProductID int `gorm:"column:product_id" json:"product_id"` - Type string `gorm:"column:type" json:"type"` - URL string `gorm:"column:url" json:"url"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + ID int `json:"id"` + ProductID int `json:"product_id"` + Type string `json:"type"` + URL string `json:"url"` + Key string `json:"-"` // clé interne RustFS, jamais exposée + CreatedAt time.Time `json:"created_at"` } func (Media) TableName() string { return "media" } diff --git a/backend/gestion/models/settings.go b/backend/gestion/models/settings.go index 90529d54..a88eeb45 100644 --- a/backend/gestion/models/settings.go +++ b/backend/gestion/models/settings.go @@ -106,4 +106,19 @@ type AppSettings struct { ShopName string `json:"shop_name"` // nom affiché dans la sidebar du site client Telegram2FAEnabled bool `json:"telegram_2fa_enabled"` // activer/désactiver l'authentification à deux facteurs ContactTelegram string `json:"contact_telegram"` // numéro de téléphone Telegram du contact + // Palette de couleurs — espace admin + AdminColorPrimary string `json:"admin_color_primary"` + AdminColorSecondary string `json:"admin_color_secondary"` + AdminColorSuccess string `json:"admin_color_success"` + AdminColorDanger string `json:"admin_color_danger"` + AdminColorWarning string `json:"admin_color_warning"` + // Palette de couleurs — app client + site web + ClientColorPrimary string `json:"client_color_primary"` + ClientColorSecondary string `json:"client_color_secondary"` + ClientColorSuccess string `json:"client_color_success"` + ClientColorDanger string `json:"client_color_danger"` + ClientColorWarning string `json:"client_color_warning"` + // Dégradé du titre boutique sur le site web client + ClientTitleGradientFrom string `json:"client_title_gradient_from"` + ClientTitleGradientTo string `json:"client_title_gradient_to"` } diff --git a/backend/gestion/models/stats.go b/backend/gestion/models/stats.go index c8b3a2ee..77bd1196 100644 --- a/backend/gestion/models/stats.go +++ b/backend/gestion/models/stats.go @@ -42,3 +42,35 @@ type DayRevenueRow struct { Day time.Time `gorm:"column:day"` Revenue float64 `gorm:"column:revenue"` } + +type DailyProductRow struct { + ProductID int `gorm:"column:product_id"` + ProductName string `gorm:"column:product_name"` + Category string `gorm:"column:category"` + CategoryColor string `gorm:"column:category_color"` + TotalQuantity float64 `gorm:"column:total_quantity"` + OrderCount int `gorm:"column:order_count"` + Revenue float64 `gorm:"column:revenue"` +} + +type DayRowWithResult struct { + Day time.Time `gorm:"column:day"` + Count int `gorm:"column:count"` + Revenue float64 `gorm:"column:revenue"` +} +type WeekRow struct { + WeekNum int `gorm:"column:week_num"` + Year int `gorm:"column:year"` + Count int `gorm:"column:count"` + Revenue float64 `gorm:"column:revenue"` +} +type MonthRow struct { + MonthNum int `gorm:"column:month_num"` + Year int `gorm:"column:year"` + Count int `gorm:"column:count"` + Revenue float64 `gorm:"column:revenue"` +} +type TodayRow struct { + Count int `gorm:"column:count"` + Revenue float64 `gorm:"column:revenue"` +} diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 006e41c6..9ca89c9b 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -9,7 +9,7 @@ import ( "github.com/gin-gonic/gin" ) -func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService) { +func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services.GeoService, s3Service *services.S3Service) { // ============================================ // 🔐 MIDDLEWARE GLOBAL @@ -17,6 +17,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services router.Use(func(c *gin.Context) { c.Set("database", database) c.Set("geoService", geoService) + c.Set("s3Service", s3Service) }) // ============================================ @@ -88,6 +89,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES cartGroupV1.GET("/my-commands/history", handlers.GetClientCommandsHistory) + // NOTATION LIVREUR + cartGroupV1.POST("/orders/:id/rate", handlers.SubmitLivreurRating) + cartGroupV1.GET("/orders/:id/rating", handlers.GetOrderRatingStatus) + // ⭐⭐ PÉNALITÉS CLIENT cartGroupV1.GET("/penalties", handlers.GetMyPenalties) // Voir mes pénalités @@ -123,7 +128,12 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ============================================ // 💸 WEBHOOK NOWPAYMENTS (v1) - PUBLIC (pas d'auth, vérifié par HMAC) // ============================================ - router.POST("/api/v1/webhook/nowpayments", handlers.IPNWebhook) + router.POST("/api/v1/webhooks/nowpayments", handlers.IPNWebhook) + + // ============================================ + // 🖼️ PROXY MÉDIAS (RustFS privé via VPN) + // ============================================ + router.GET("/media/*key", handlers.ServeMedia) // ============================================ // 🤖 WEBHOOK TELEGRAM - PUBLIC (sécurisé par secret header) @@ -135,6 +145,11 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ============================================ router.POST("/api/internal/telegram/link", handlers.InternalTelegramLink) + // ============================================ + // 📋 HEALTH CHECK + // ============================================ + router.GET("/health", handlers.Health) + // ============================================ // 📋 PATTERN v2: ADMIN API // ============================================ @@ -197,16 +212,18 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // CATÉGORIES - GESTION ADMIN // ============================================ adminGroupV2.POST("/categories", handlers.CreateCategory) + adminGroupV2.PUT("/categories/reorder", handlers.ReorderCategories) adminGroupV2.PUT("/categories/:id", handlers.UpdateCategory) adminGroupV2.DELETE("/categories/:id", handlers.DeleteCategory) // ============================================ // STATISTIQUES ADMIN // ============================================ adminGroupV2.GET("/stats", handlers.GetAdminStats) - + adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats) + adminGroupV2.GET("/stats/monthly", handlers.GetAdminStatsByMonth) adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice) adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice) - + adminGroupV2.GET("/stats/daily", handlers.GetAdminDailyDetail) // ============================================ // COMMANDES - GESTION DE BASE // ============================================ @@ -260,6 +277,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.PUT("/delivery-persons/update/:username/location", handlers.UpdateDeliveryPersonLocationAdmin) adminGroupV2.DELETE("/delivery-persons/:username/queue/:command_id", handlers.RemoveCommandFromQueue) adminGroupV2.GET("/delivery-persons/:username/map-links", handlers.GetDeliveryPersonMapLinks) + adminGroupV2.GET("/delivery-persons/:username/ratings", handlers.GetLivreurRatings) + adminGroupV2.GET("/delivery-persons/:username/login-history", handlers.GetLivreurLoginHistory) // Commandes annulées adminGroupV2.GET("/orders/cancelled", handlers.GetAllCancelledOrders) // ============================================ @@ -392,6 +411,8 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ============================================ // NOTIFICATIONS LIVREUR // ============================================ + livreurGroupV1.GET("/ratings", handlers.GetMyRatings) + livreurGroupV1.GET("/notifications", handlers.GetLivreurNotifications) livreurGroupV1.POST("/notifications/read", handlers.MarkLivreurNotificationsRead) diff --git a/backend/gestion/services/adresses_correction.go b/backend/gestion/services/adresses_correction.go index b879d6a6..ea2a30f8 100644 --- a/backend/gestion/services/adresses_correction.go +++ b/backend/gestion/services/adresses_correction.go @@ -3,17 +3,13 @@ package services import ( "encoding/json" "fmt" + "gestion/utils" "io" "math" "net/http" "net/url" "strings" "time" - "unicode" - - "golang.org/x/text/runes" - "golang.org/x/text/transform" - "golang.org/x/text/unicode/norm" ) // ============================================ @@ -64,25 +60,24 @@ func NewAddressCorrectionService(geoService *GeoService) *AddressCorrectionServi } } -// ============================================ -// POINT D'ENTRÉE PRINCIPAL -// ============================================ - -// ResolveAddress tente de géocoder une adresse avec correction automatique. -// Retourne toujours une suggestion, même approximative. -// Ordre de résolution : -// 1. Géocodage exact → succès immédiat -// 2. Nominatim fuzzy search (addressdetails + limit=5) -// 3. Décomposition structurée de l'adresse -// 4. Erreur explicite avec suggestions si dispo func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*AddressSuggestion, error) { rawAddress = strings.TrimSpace(rawAddress) if rawAddress == "" { return nil, fmt.Errorf("adresse vide") } - // ── Étape 1 : essai exact via GeoService (utilise le cache Redis) ── - if loc, err := acs.geoService.GeocodeAddress(rawAddress); err == nil { + if loc, err := acs.geoService.getFromCache(rawAddress); err == nil { + return &AddressSuggestion{ + OriginalAddress: rawAddress, + CorrectedAddress: rawAddress, + Coordinates: Coordinates{Latitude: loc.Latitude, Longitude: loc.Longitude}, + Confidence: 1.0, + CorrectionApplied: false, + Source: "exact", + }, nil + } + if loc, err := acs.geoService.fetchFromNominatim(rawAddress); err == nil { + acs.geoService.saveToCache(rawAddress, loc) return &AddressSuggestion{ OriginalAddress: rawAddress, CorrectedAddress: rawAddress, @@ -106,11 +101,6 @@ func (acs *AddressCorrectionService) ResolveAddress(rawAddress string) (*Address return nil, fmt.Errorf("adresse introuvable : '%s' — vérifiez l'orthographe ou le code postal", rawAddress) } -// ============================================ -// ÉTAPE 2 : FUZZY SEARCH NOMINATIM -// ============================================ - -// nominatimFuzzySearch interroge Nominatim avec plusieurs variantes de l'adresse func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*AddressSuggestion, error) { variants := buildAddressVariants(address) @@ -131,7 +121,7 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr CorrectedAddress: corrected, Coordinates: Coordinates{Latitude: best.Latitude, Longitude: best.Longitude}, Confidence: confidence, - CorrectionApplied: !strings.EqualFold(normalize(address), normalize(corrected)), + CorrectionApplied: !strings.EqualFold(utils.NormalizeAddress(address), utils.NormalizeAddress(corrected)), Source: "fuzzy", }, nil } @@ -140,7 +130,6 @@ func (acs *AddressCorrectionService) nominatimFuzzySearch(address string) (*Addr return nil, fmt.Errorf("aucune correspondance fuzzy trouvée") } -// queryNominatim exécute une requête vers l'API Nominatim func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([]NominatimSuggestion, error) { query = strings.TrimSpace(query) if query == "" { @@ -172,7 +161,7 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([] defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("Nominatim status %d", resp.StatusCode) + return nil, fmt.Errorf("nominatim status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) @@ -196,7 +185,6 @@ func (acs *AddressCorrectionService) queryNominatim(query string, limit int) ([] func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressSuggestion, error) { parts := parseAddressParts(address) - // Essai 1 : numéro + rue + ville (sans code postal) if parts.streetNumber != "" && parts.streetName != "" && parts.city != "" { q := fmt.Sprintf("%s %s, %s", parts.streetNumber, parts.streetName, parts.city) if s, err := acs.nominatimFuzzySearch(q); err == nil { @@ -206,7 +194,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS } } - // Essai 2 : rue + code postal uniquement if parts.streetName != "" && parts.postcode != "" { q := fmt.Sprintf("%s, %s", parts.streetName, parts.postcode) if s, err := acs.nominatimFuzzySearch(q); err == nil { @@ -216,7 +203,6 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS } } - // Essai 3 : ville + code postal comme zone de repli if parts.city != "" && parts.postcode != "" { q := fmt.Sprintf("%s %s, France", parts.city, parts.postcode) suggestions, err := acs.queryNominatim(q, 3) @@ -243,7 +229,7 @@ func (acs *AddressCorrectionService) structuredSearch(address string) (*AddressS // buildAddressVariants génère plusieurs variantes d'une adresse pour maximiser les chances func buildAddressVariants(address string) []string { variants := []string{address} - normalized := normalize(address) + normalized := utils.NormalizeAddress(address) // Variante sans accents if normalized != address { @@ -387,8 +373,8 @@ func parseAddressParts(address string) addressParts { // computeConfidence calcule un score de similarité entre l'adresse originale et la suggestion func computeConfidence(original, suggested string, nominatimImportance float64) float64 { - origNorm := normalize(strings.ToLower(original)) - suggNorm := normalize(strings.ToLower(suggested)) + origNorm := utils.NormalizeAddress(strings.ToLower(original)) + suggNorm := utils.NormalizeAddress(strings.ToLower(suggested)) // Score de similarité sur les mots communs origWords := strings.Fields(origNorm) @@ -449,13 +435,6 @@ func formatNominatimAddress(s NominatimSuggestion) string { return strings.Join(parts, ", ") } -// normalize supprime les accents et normalise les espaces -func normalize(s string) string { - t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) - result, _, _ := transform.String(t, s) - return strings.Join(strings.Fields(result), " ") -} - // isPostcode retourne true si le mot ressemble à un code postal français func isPostcode(s string) bool { if len(s) != 5 { diff --git a/backend/gestion/services/geo_services.go b/backend/gestion/services/geo_services.go index 7353d4a9..170eaffa 100644 --- a/backend/gestion/services/geo_services.go +++ b/backend/gestion/services/geo_services.go @@ -72,19 +72,14 @@ func (gs *GeoService) GeocodeAddress(address string) (*GeoLocation, error) { return location, nil } - // 2. TomTom (primaire — plus fiable que Nominatim pour les adresses FR) - if location, err := GeocodeWithTomTom(address); err == nil { - gs.saveToCache(address, location) - return location, nil - } - - // 3. Fallback Nominatim + // 2. Tentative directe via Nominatim if location, err := gs.fetchFromNominatim(address); err == nil { gs.saveToCache(address, location) return location, nil } - // 4. ── Correction automatique de l'adresse ──────────────────────────── + // 3. ── NOUVEAU : correction automatique de l'adresse ────────────────── + // Déclenché uniquement si le géocodage direct a échoué. log.Printf("🔍 [GEO] Géocodage direct échoué pour '%s', tentative de correction...", address) suggestion, err := gs.correctionService.ResolveAddress(address) @@ -216,10 +211,6 @@ func (gs *GeoService) getCacheKey(address string) string { return fmt.Sprintf("geocode:cache:%s", address) } -// ============================================ -// CALCULS GÉOGRAPHIQUES -// ============================================ - // CalculateDistance calcule la distance entre deux points (formule Haversine) func CalculateDistance(from, to Coordinates) float64 { // Conversion en radians @@ -228,7 +219,6 @@ func CalculateDistance(from, to Coordinates) float64 { lat2Rad := toRadians(to.Latitude) lon2Rad := toRadians(to.Longitude) - // Différences dLat := lat2Rad - lat1Rad dLon := lon2Rad - lon1Rad @@ -244,13 +234,10 @@ func CalculateDistance(from, to Coordinates) float64 { // CalculateETA calcule le temps estimé d'arrivée en minutes (version locale/fallback) func CalculateETA(distanceKm float64) int { - // ⚡ AMÉLIORATION: Formule plus réaliste basée sur la distance if distanceKm < 0.1 { - return MinETA // Très proche: minimum 3 minutes + return MinETA } - // Temps de trajet basé sur vitesse moyenne en ville (25 km/h avec trafic) - // Plus réaliste que 30 km/h travelTime := (distanceKm / 25.0) * 60.0 // Ajouter une marge pour le trafic (environ 20%) @@ -267,8 +254,6 @@ func CalculateETA(distanceKm float64) int { return totalMinutes } -// CalculateETAWithTomTom calcule l'ETA via TomTom API (précis avec trafic réel) -// Retourne (etaMinutes, distanceKm, error) func CalculateETAWithTomTom(from, to Coordinates) (int, float64, error) { if len(tomTomKeys.keys) == 0 { distance := CalculateDistance(from, to) diff --git a/backend/gestion/services/lbtelegram.go b/backend/gestion/services/lbtelegram.go index 094d01d9..041306fb 100644 --- a/backend/gestion/services/lbtelegram.go +++ b/backend/gestion/services/lbtelegram.go @@ -42,7 +42,7 @@ func (s *LBTelegramService) IsConfigured() bool { // EnrollUser enrôle un utilisateur auprès de LBTelegram après liaison du compte. // LBTelegram envoie lui-même le message de confirmation (chaîne Bot1→Bot2→Bot3). func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "user_id": chatID, "username": username, "role": role, @@ -68,7 +68,7 @@ func (s *LBTelegramService) EnrollUser(chatID int64, username, role string) erro // SendNotification envoie un message via la gateway LBTelegram. // Le bot est choisi automatiquement selon la stratégie configurée (failover/roundrobin/leastconn). func (s *LBTelegramService) SendNotification(userID int64, message string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "user_id": userID, "message": message, } diff --git a/backend/gestion/services/s3.go b/backend/gestion/services/s3.go new file mode 100644 index 00000000..fd3583ea --- /dev/null +++ b/backend/gestion/services/s3.go @@ -0,0 +1,135 @@ +package services + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/google/uuid" +) + +type S3Service struct { + client *s3.Client + bucketName string +} + +type S3Credentials struct { + S3KeyId string + S3AccessKey string +} + +// NewS3Service initialise le client S3 pointant vers RustFS (accessible via VPN). +func NewS3Service(region, bucketName, endpoint string, creds S3Credentials) (*S3Service, error) { + var cfg aws.Config + var err error + + if creds.S3KeyId != "" && creds.S3AccessKey != "" { + cfg, err = config.LoadDefaultConfig(context.TODO(), + config.WithRegion(region), + config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider(creds.S3KeyId, creds.S3AccessKey, ""), + ), + ) + } else { + cfg, err = config.LoadDefaultConfig(context.TODO(), config.WithRegion(region)) + } + if err != nil { + return nil, fmt.Errorf("erreur chargement config AWS: %w", err) + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + if endpoint != "" { + o.BaseEndpoint = aws.String(endpoint) // ex: http://10.x.x.x:9000 (IP interne VPN de RustFS) + o.UsePathStyle = true + } + }) + + return &S3Service{ + client: client, + bucketName: bucketName, + }, nil +} + +// UploadFile upload un fichier et renvoie sa clé S3 (pas d'URL publique, RustFS est privé). +func (s *S3Service) UploadFile(fileHeader *multipart.FileHeader, folder string) (key string, err error) { + ext := filepath.Ext(fileHeader.Filename) + fileName := fmt.Sprintf("%s%s", uuid.New().String(), ext) + return s.UploadFileWithName(fileHeader, folder, fileName) +} + +// UploadFileWithName upload un fichier avec un nom déjà déterminé et renvoie la clé S3. +func (s *S3Service) UploadFileWithName(fileHeader *multipart.FileHeader, folder, fileName string) (key string, err error) { + file, err := fileHeader.Open() + if err != nil { + return "", fmt.Errorf("erreur ouverture fichier: %w", err) + } + defer file.Close() + + buf := bytes.NewBuffer(nil) + if _, err := buf.ReadFrom(file); err != nil { + return "", fmt.Errorf("erreur lecture fichier: %w", err) + } + + key = fmt.Sprintf("%s/%s", folder, fileName) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + contentType := fileHeader.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + + _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(key), + Body: bytes.NewReader(buf.Bytes()), + ContentType: aws.String(contentType), + }) + if err != nil { + return "", fmt.Errorf("erreur upload RustFS: %w", err) + } + + return key, nil +} + +// GetFile récupère un objet depuis RustFS (stream + content-type) pour le proxy. +// Le contexte doit rester actif pendant toute la lecture du body par l'appelant. +func (s *S3Service) GetFile(ctx context.Context, key string) (io.ReadCloser, string, error) { + out, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(key), + }) + if err != nil { + return nil, "", fmt.Errorf("erreur lecture RustFS: %w", err) + } + + contentType := "application/octet-stream" + if out.ContentType != nil { + contentType = *out.ContentType + } + return out.Body, contentType, nil +} + +// DeleteFile supprime un fichier à partir de sa clé S3. +func (s *S3Service) DeleteFile(key string) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.bucketName), + Key: aws.String(key), + }) + if err != nil { + return fmt.Errorf("erreur suppression RustFS: %w", err) + } + return nil +} diff --git a/backend/gestion/services/storage.go b/backend/gestion/services/storage.go new file mode 100644 index 00000000..e9df2c14 --- /dev/null +++ b/backend/gestion/services/storage.go @@ -0,0 +1,105 @@ +package services + +import ( + "fmt" + "gestion/utils" + "io" + "mime/multipart" + "os" + "path/filepath" +) + +// Storage abstrait l'emplacement de stockage des médias produits (local ou S3), +// pour que tous les points d'upload/suppression respectent le même driver. +type Storage interface { + // Upload sauvegarde le fichier et renvoie l'URL à persister en DB (models.Media.URL) + // et la clé interne (vide pour local, clé S3 sinon — models.Media.Key). + Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) + // Delete supprime le fichier. url et key sont ceux stockés en DB pour ce média : + // chaque implémentation ignore celui qui ne la concerne pas. + Delete(url string, key string) error +} + +// LocalStorage stocke les fichiers sur le disque local, sous baseDir (ex: "uploads"). +type LocalStorage struct { + baseDir string +} + +func NewLocalStorage(baseDir string) *LocalStorage { + return &LocalStorage{baseDir: baseDir} +} + +func (s *LocalStorage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) { + destFolder := filepath.Join(s.baseDir, folder) + if err := os.MkdirAll(destFolder, 0750); err != nil { + return "", "", fmt.Errorf("erreur création dossier: %w", err) + } + + filePath := filepath.Join(destFolder, fileName) + safeFilePath, err := utils.SanitizeFilePath(filePath) + if err != nil { + return "", "", fmt.Errorf("chemin invalide: %w", err) + } + + src, err := fileHeader.Open() + if err != nil { + return "", "", fmt.Errorf("erreur ouverture fichier: %w", err) + } + defer src.Close() + + dst, err := os.OpenFile(safeFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0640) + if err != nil { + return "", "", fmt.Errorf("erreur création fichier: %w", err) + } + defer dst.Close() + + if _, err := io.Copy(dst, src); err != nil { + os.Remove(safeFilePath) + return "", "", fmt.Errorf("erreur écriture fichier: %w", err) + } + + return "/" + filepath.ToSlash(safeFilePath), "", nil +} + +func (s *LocalStorage) Delete(url string, key string) error { + filePath := "" + if len(url) > 0 && url[0] == '/' { + filePath = url[1:] + } else { + filePath = url + } + + safeFilePath, err := utils.SanitizeFilePath(filePath) + if err != nil { + return fmt.Errorf("chemin invalide: %w", err) + } + + if err := os.Remove(safeFilePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("erreur suppression: %w", err) + } + return nil +} + +// S3Storage adapte le S3Service existant (RustFS) à l'interface Storage. +type S3Storage struct { + s3 *S3Service +} + +func NewS3Storage(s3 *S3Service) *S3Storage { + return &S3Storage{s3: s3} +} + +func (s *S3Storage) Upload(fileHeader *multipart.FileHeader, folder, fileName string) (url string, key string, err error) { + key, err = s.s3.UploadFileWithName(fileHeader, folder, fileName) + if err != nil { + return "", "", err + } + return "/media/" + key, key, nil +} + +func (s *S3Storage) Delete(url string, key string) error { + if key == "" { + return fmt.Errorf("clé S3 manquante pour suppression") + } + return s.s3.DeleteFile(key) +} diff --git a/backend/gestion/services/telegram.go b/backend/gestion/services/telegram.go index 6a035ab4..a7cd9a37 100644 --- a/backend/gestion/services/telegram.go +++ b/backend/gestion/services/telegram.go @@ -64,7 +64,7 @@ func (t *TelegramService) SendMessage(chatID int64, text string) error { return fmt.Errorf("telegram non configuré") } - payload := map[string]interface{}{ + payload := map[string]any{ "chat_id": chatID, "text": text, "parse_mode": "HTML", @@ -107,11 +107,11 @@ func (t *TelegramService) SendMessageWithButtons(chatID int64, text string, butt row = append(row, map[string]string{"text": b[0], "url": b[1]}) } - payload := map[string]interface{}{ + payload := map[string]any{ "chat_id": chatID, "text": text, "parse_mode": "HTML", - "reply_markup": map[string]interface{}{ + "reply_markup": map[string]any{ "inline_keyboard": [][]map[string]string{row}, }, } @@ -147,7 +147,7 @@ func (t *TelegramService) SetWebhook(webhookURL string) error { return fmt.Errorf("telegram non configuré") } - payload := map[string]interface{}{ + payload := map[string]any{ "url": webhookURL, "allowed_updates": []string{"message"}, } diff --git a/backend/gestion/services/tomtom.go b/backend/gestion/services/tomtom.go index 7a3b8ef7..01c7021a 100644 --- a/backend/gestion/services/tomtom.go +++ b/backend/gestion/services/tomtom.go @@ -15,72 +15,6 @@ import ( "time" ) -// GeocodeWithTomTom géocode une adresse via l'API TomTom Search. -func GeocodeWithTomTom(address string) (*GeoLocation, error) { - client := &http.Client{Timeout: 10 * time.Second} - - buildReq := func(key string) (*http.Request, error) { - u := &url.URL{ - Scheme: "https", - Host: "api.tomtom.com", - Path: fmt.Sprintf("/search/2/geocode/%s.json", url.PathEscape(address)), - } - q := url.Values{} - q.Set("key", key) - q.Set("countrySet", "FR") - q.Set("limit", "1") - u.RawQuery = q.Encode() - return http.NewRequest(http.MethodGet, u.String(), nil) - } - - resp, err := tomTomKeys.Do(client, buildReq) - if err != nil { - return nil, fmt.Errorf("TomTom geocode: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("TomTom geocode %d: %s", resp.StatusCode, string(body)) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("TomTom geocode lecture: %w", err) - } - - var parsed struct { - Results []struct { - Position struct { - Lat float64 `json:"lat"` - Lon float64 `json:"lon"` - } `json:"position"` - Address struct { - FreeformAddress string `json:"freeformAddress"` - } `json:"address"` - MatchConfidence struct { - Score float64 `json:"score"` - } `json:"matchConfidence"` - } `json:"results"` - } - if err := json.Unmarshal(body, &parsed); err != nil { - return nil, fmt.Errorf("TomTom geocode parsing: %w", err) - } - if len(parsed.Results) == 0 { - return nil, fmt.Errorf("TomTom geocode: aucun résultat pour '%s'", address) - } - - r := parsed.Results[0] - log.Printf("📍 [GEO] TomTom geocode '%s' → %s (%.6f, %.6f) conf=%.2f", - address, r.Address.FreeformAddress, r.Position.Lat, r.Position.Lon, r.MatchConfidence.Score) - - return &GeoLocation{ - Latitude: r.Position.Lat, - Longitude: r.Position.Lon, - DisplayName: r.Address.FreeformAddress, - }, nil -} - func GetETAWithTraffic(from, to Coordinates) (etaMinutes int, distanceKm float64, err error) { client := &http.Client{Timeout: 10 * time.Second} diff --git a/backend/gestion/services/tomtom_keys.go b/backend/gestion/services/tomtom_keys.go index 7ac2caea..e87081db 100644 --- a/backend/gestion/services/tomtom_keys.go +++ b/backend/gestion/services/tomtom_keys.go @@ -59,6 +59,7 @@ func (m *tomTomKeyManager) rotate(fromIdx int) { } // Do exécute la requête en rotant automatiquement sur 403/429. +// buildReq doit construire une nouvelle *http.Request pour la clé donnée. func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*http.Request, error)) (*http.Response, error) { n := len(m.keys) if n == 0 { @@ -67,27 +68,23 @@ func (m *tomTomKeyManager) Do(client *http.Client, buildReq func(key string) (*h _, startIdx := m.currentKey() - for attempt := 0; attempt < n; attempt++ { + for attempt := range n { idx := (startIdx + attempt) % n key := m.keys[idx] - req, err := buildReq(key) if err != nil { return nil, err } - resp, err := client.Do(req) if err != nil { return nil, err } - if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusTooManyRequests { io.Copy(io.Discard, resp.Body) resp.Body.Close() m.rotate(idx) continue } - return resp, nil } diff --git a/backend/gestion/tests/address_correction_integration_test.go b/backend/gestion/tests/address_correction_integration_test.go new file mode 100644 index 00000000..caa332d3 --- /dev/null +++ b/backend/gestion/tests/address_correction_integration_test.go @@ -0,0 +1,126 @@ +package tests + +import ( + "gestion/db" + "gestion/services" + "testing" + "time" +) + +// Ces tests appellent le vrai service Nominatim (réseau réel, rate-limité à +// 1 req/s — voir services/adresses_correction.go). Contrairement aux tests +// purs de services/adresses_correction_test.go (normalisation, décomposition, +// scoring — sans réseau), ceux-ci vérifient le comportement de bout en bout +// de ResolveAddress sur de vraies adresses nantaises mal écrites. +// +// Chaque cas a été vérifié manuellement au préalable (curl vers l'API +// Nominatim) pour confirmer ce que la recherche directe résout déjà seule +// (Nominatim tolère nativement la casse, les accents et certaines +// abréviations sans point) et ce qui nécessite réellement la logique de +// correction (variantes, décomposition structurée, repli ville+code postal). +// +// Un délai explicite sépare chaque cas, en plus du throttle déjà appliqué à +// chaque requête HTTP interne (1.1s dans queryNominatim), par courtoisie +// envers le service public. + +const ( + nantesLatMin, nantesLatMax = 47.15, 47.28 + nantesLonMin, nantesLonMax = -1.65, -1.45 +) + +func isWithinNantes(lat, lon float64) bool { + return lat >= nantesLatMin && lat <= nantesLatMax && lon >= nantesLonMin && lon <= nantesLonMax +} + +func TestResolveAddress_RealNantesAddresses(t *testing.T) { + if testing.Short() { + t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short") + } + + geoService := services.NewGeoService(db.Redis, db.RedisCtx) + correction := services.NewAddressCorrectionService(geoService) + + cases := []struct { + name string + input string + minConfidence float64 + maxConfidence float64 + }{ + { + // Nominatim tolère nativement la casse et l'absence d'accent : + // résolution directe (étape 1 de ResolveAddress), confiance max. + name: "tout minuscule sans accent", + input: "12 rue crebillon 44000 nantes", + minConfidence: 0.90, + maxConfidence: 1.0, + }, + { + // Abréviation sans point ("Pl" au lieu de "Place") — également + // tolérée nativement par Nominatim, résolution directe. + name: "abréviation sans point", + input: "3 Pl Royale 44000 Nantes", + minConfidence: 0.90, + maxConfidence: 1.0, + }, + { + // Faute de frappe réaliste sur un nom de rue réel (Gambetta -> + // Gambeta) : vérifié que la recherche Nominatim directe ET + // toutes les variantes générées par l'algorithme (accents, + // abréviations, décomposition structurée essais 1 et 2) + // échouent — seul le repli ville+code postal (essai 3, + // confiance fixe 0.30) aboutit. Documente une vraie limite : + // l'algorithme ne corrige pas les fautes de frappe arbitraires + // dans un nom de rue, il retombe sur "quelque part dans la + // bonne ville". + name: "faute de frappe non corrigible sur le nom de rue", + input: "15 Rue Gambeta 44000 Nantes", + minConfidence: 0.25, + maxConfidence: 0.35, + }, + } + + for i, c := range cases { + t.Run(c.name, func(t *testing.T) { + if i > 0 { + time.Sleep(1200 * time.Millisecond) + } + + suggestion, err := correction.ResolveAddress(c.input) + if err != nil { + t.Fatalf("ResolveAddress(%q): %v", c.input, err) + } + + if !isWithinNantes(suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) { + t.Errorf("coordonnées hors de Nantes pour %q: lat=%.4f lon=%.4f", + c.input, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) + } + if suggestion.Confidence < c.minConfidence || suggestion.Confidence > c.maxConfidence { + t.Errorf("confiance hors intervalle attendu pour %q: got=%.2f want=[%.2f,%.2f]", + c.input, suggestion.Confidence, c.minConfidence, c.maxConfidence) + } + if suggestion.CorrectedAddress == "" { + t.Errorf("adresse corrigée vide pour %q", c.input) + } + + t.Logf("%q -> %q (confiance=%.2f, source=%s, correction_appliquée=%v, lat=%.4f lon=%.4f)", + c.input, suggestion.CorrectedAddress, suggestion.Confidence, suggestion.Source, + suggestion.CorrectionApplied, suggestion.Coordinates.Latitude, suggestion.Coordinates.Longitude) + }) + } +} + +// Une adresse totalement absurde (aucun rapport avec un lieu réel) doit +// échouer proprement plutôt que renvoyer une coordonnée aléatoire. +func TestResolveAddress_NonsenseAddressFailsCleanly(t *testing.T) { + if testing.Short() { + t.Skip("appelle le vrai service Nominatim en réseau — sauté en mode -short") + } + + geoService := services.NewGeoService(db.Redis, db.RedisCtx) + correction := services.NewAddressCorrectionService(geoService) + + _, err := correction.ResolveAddress("Xyzzyplonk Zorbaxx 00000 Nullepart") + if err == nil { + t.Fatal("attendu une erreur pour une adresse sans aucun rapport avec un lieu réel") + } +} diff --git a/backend/gestion/tests/address_correction_test.go b/backend/gestion/tests/address_correction_test.go new file mode 100644 index 00000000..5317ed1f --- /dev/null +++ b/backend/gestion/tests/address_correction_test.go @@ -0,0 +1,243 @@ +package tests + +import ( + "gestion/models" + "testing" +) + +// db_address.go gère une table de correspondances gérées par l'admin +// (adresse_correction) : à chaque checkout, CheckAddress vérifie si l'adresse +// saisie par le client correspond à une entrée connue comme invalide, et si +// oui, substitue l'adresse correcte tout en signalant une erreur pour forcer +// une nouvelle confirmation côté client (voir ValidateBasket). + +func cleanupAddressCorrections(t *testing.T, ids ...int64) { + t.Helper() + t.Cleanup(func() { + for _, id := range ids { + testDB.GDB.Exec(`DELETE FROM adresse_correction WHERE id = ?`, id) + } + }) +} + +func TestCheckAddress_NoMatchReturnsNilAndLeavesAddressUnchanged(t *testing.T) { + cmd := &models.Command{DeliveryAddress: testUserPrefix + "adresse jamais enregistrée 44000 Nantes"} + original := cmd.DeliveryAddress + + if err := testDB.CheckAddress(cmd); err != nil { + t.Fatalf("CheckAddress sans correspondance ne doit jamais échouer: %v", err) + } + if cmd.DeliveryAddress != original { + t.Errorf("adresse ne doit pas être modifiée sans correspondance: got=%q want=%q", cmd.DeliveryAddress, original) + } +} + +func TestCheckAddress_MatchSubstitutesCorrectAddressAndReturnsError(t *testing.T) { + invalid := testUserPrefix + "12 Rue Crebillon Nantes" + correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes" + if err := testDB.AddAddress(correct, invalid); err != nil { + t.Fatalf("AddAddress: %v", err) + } + var id int64 + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id) + cleanupAddressCorrections(t, id) + + cmd := &models.Command{DeliveryAddress: invalid} + err := testDB.CheckAddress(cmd) + if err == nil { + t.Fatal("attendu une erreur signalant la correction (pour forcer une re-confirmation client)") + } + if cmd.DeliveryAddress != correct { + t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct) + } +} + +// addCorrectionForFallback enregistre une correction et retourne une fonction +// de nettoyage à appeler via t.Cleanup par l'appelant (évite de dépendre de +// l'ordre d'exécution entre plusieurs corrections ajoutées dans un même test). +func addCorrectionForFallback(t *testing.T, invalid, correct string) { + t.Helper() + if err := testDB.AddAddress(correct, invalid); err != nil { + t.Fatalf("AddAddress(%q -> %q): %v", invalid, correct, err) + } + var id int64 + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id) + cleanupAddressCorrections(t, id) +} + +// Les quatre tests suivants couvrent le fallback normalisé de CheckAddress +// (utils.NormalizeAddress + strings.EqualFold) : une correction enregistrée +// par l'admin avec un texte exact donné doit continuer à s'appliquer même si +// le client tape une variante mineure (casse, accents, espaces), plutôt que +// d'échouer silencieusement et laisser passer une adresse non livrable. + +func TestCheckAddress_NormalizedFallback_CaseVariantMatches(t *testing.T) { + invalid := testUserPrefix + "12 Rue Crebillon Nantes" + correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes" + addCorrectionForFallback(t, invalid, correct) + + cmd := &models.Command{DeliveryAddress: testUserPrefix + "12 RUE CREBILLON NANTES"} + err := testDB.CheckAddress(cmd) + if err == nil { + t.Fatal("attendu une erreur signalant la correction (variante de casse)") + } + if cmd.DeliveryAddress != correct { + t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct) + } +} + +func TestCheckAddress_NormalizedFallback_AccentVariantMatches(t *testing.T) { + invalid := testUserPrefix + "10 Rue du Général Buat Nantes" + correct := testUserPrefix + "10 Rue du Général Buat, 44000 Nantes" + addCorrectionForFallback(t, invalid, correct) + + // Saisie sans accent par le client, alors que la correction enregistrée + // par l'admin en contient un. + cmd := &models.Command{DeliveryAddress: testUserPrefix + "10 Rue du General Buat Nantes"} + err := testDB.CheckAddress(cmd) + if err == nil { + t.Fatal("attendu une erreur signalant la correction (variante d'accent)") + } + if cmd.DeliveryAddress != correct { + t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct) + } +} + +func TestCheckAddress_NormalizedFallback_WhitespaceVariantMatches(t *testing.T) { + invalid := testUserPrefix + "5 Cours des 50 Otages Nantes" + correct := testUserPrefix + "5 Cours des 50 Otages, 44000 Nantes" + addCorrectionForFallback(t, invalid, correct) + + cmd := &models.Command{DeliveryAddress: testUserPrefix + "5 Cours des 50 Otages Nantes "} + err := testDB.CheckAddress(cmd) + if err == nil { + t.Fatal("attendu une erreur signalant la correction (espaces multiples)") + } + if cmd.DeliveryAddress != correct { + t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct) + } +} + +func TestCheckAddress_NormalizedFallback_CombinedCaseAccentWhitespaceMatches(t *testing.T) { + invalid := testUserPrefix + "8 Rue de Verdun Nantes" + correct := testUserPrefix + "8 Rue de Verdun, 44000 Nantes" + addCorrectionForFallback(t, invalid, correct) + + cmd := &models.Command{DeliveryAddress: testUserPrefix + "8 RUE de verdun nantes "} + err := testDB.CheckAddress(cmd) + if err == nil { + t.Fatal("attendu une erreur signalant la correction (casse + espaces combinés)") + } + if cmd.DeliveryAddress != correct { + t.Errorf("adresse corrigée: got=%q want=%q", cmd.DeliveryAddress, correct) + } +} + +// Le fallback compare une égalité normalisée stricte, pas une similarité +// floue : une adresse réellement différente (même partiellement proche) ne +// doit jamais être substituée par erreur. +func TestCheckAddress_NormalizedFallback_DoesNotMatchDifferentAddress(t *testing.T) { + invalid := testUserPrefix + "12 Rue Crebillon Nantes" + correct := testUserPrefix + "12 Rue Crébillon, 44000 Nantes" + addCorrectionForFallback(t, invalid, correct) + + cmd := &models.Command{DeliveryAddress: testUserPrefix + "14 Rue Crebillon Nantes"} + original := cmd.DeliveryAddress + if err := testDB.CheckAddress(cmd); err != nil { + t.Fatalf("une adresse différente ne doit pas déclencher de correction: %v", err) + } + if cmd.DeliveryAddress != original { + t.Errorf("adresse ne doit pas être modifiée: got=%q want=%q", cmd.DeliveryAddress, original) + } +} + +// Avec plusieurs corrections enregistrées, le fallback doit retrouver la +// bonne entrée (pas la première venue) même via une variante normalisée. +func TestCheckAddress_NormalizedFallback_FindsRightEntryAmongMultiple(t *testing.T) { + invalidA := testUserPrefix + "1 Rue A Nantes" + correctA := testUserPrefix + "1 Rue A, 44000 Nantes" + invalidB := testUserPrefix + "2 Rue B Nantes" + correctB := testUserPrefix + "2 Rue B, 44000 Nantes" + addCorrectionForFallback(t, invalidA, correctA) + addCorrectionForFallback(t, invalidB, correctB) + + cmd := &models.Command{DeliveryAddress: testUserPrefix + "2 RUE b nantes"} + if err := testDB.CheckAddress(cmd); err == nil { + t.Fatal("attendu une erreur signalant la correction B") + } + if cmd.DeliveryAddress != correctB { + t.Errorf("adresse corrigée: got=%q want=%q (ne doit pas confondre avec A)", cmd.DeliveryAddress, correctB) + } +} + +func TestAddAddress_ThenAllAddressIncludesIt(t *testing.T) { + invalid := testUserPrefix + "adresse invalide test" + correct := testUserPrefix + "adresse correcte test" + if err := testDB.AddAddress(correct, invalid); err != nil { + t.Fatalf("AddAddress: %v", err) + } + var id int64 + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalid).Scan(&id) + cleanupAddressCorrections(t, id) + + all, err := testDB.AllAddress() + if err != nil { + t.Fatalf("AllAddress: %v", err) + } + found := false + for _, a := range all { + if a.InvalidAddress == invalid && a.CorrectAddress == correct { + found = true + break + } + } + if !found { + t.Errorf("la correspondance ajoutée n'apparaît pas dans AllAddress") + } +} + +// DeleteAddress doit cibler la correspondance exacte, sans affecter une autre +// correspondance non liée. Note : invalid_address a une contrainte UNIQUE en +// base (adresse_correction_invalid_address_key), donc deux corrections ne +// peuvent jamais partager la même adresse invalide — le risque réel est +// seulement qu'un DELETE mal ciblé touche une correspondance différente. +func TestDeleteAddress_RemovesOnlyTargetedPairNotUnrelatedOne(t *testing.T) { + invalidA := testUserPrefix + "adresse A" + correctA := testUserPrefix + "correction A" + invalidB := testUserPrefix + "adresse B" + correctB := testUserPrefix + "correction B" + if err := testDB.AddAddress(correctA, invalidA); err != nil { + t.Fatalf("AddAddress A: %v", err) + } + if err := testDB.AddAddress(correctB, invalidB); err != nil { + t.Fatalf("AddAddress B: %v", err) + } + var idA, idB int64 + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidA).Scan(&idA) + testDB.GDB.Raw(`SELECT id FROM adresse_correction WHERE invalid_address = ?`, invalidB).Scan(&idB) + cleanupAddressCorrections(t, idA, idB) + + if err := testDB.DeleteAddress(invalidA, correctA); err != nil { + t.Fatalf("DeleteAddress: %v", err) + } + + all, err := testDB.AllAddress() + if err != nil { + t.Fatalf("AllAddress: %v", err) + } + var stillHasA, stillHasB bool + for _, a := range all { + if a.InvalidAddress == invalidA && a.CorrectAddress == correctA { + stillHasA = true + } + if a.InvalidAddress == invalidB && a.CorrectAddress == correctB { + stillHasB = true + } + } + if stillHasA { + t.Error("la correspondance ciblée (A) doit être supprimée") + } + if !stillHasB { + t.Error("l'autre correspondance (B), non ciblée, ne doit pas être supprimée") + } +} diff --git a/backend/gestion/tests/alert_test.go b/backend/gestion/tests/alert_test.go new file mode 100644 index 00000000..db5467b8 --- /dev/null +++ b/backend/gestion/tests/alert_test.go @@ -0,0 +1,273 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +func alertContext(username, role string, body []byte, alertID int) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/livreur/alert", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + if username != "" { + c.Set("username", username) + } + c.Set("role", role) + if alertID != 0 { + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", alertID)}} + } + return c, rec +} + +func createTestAlert(t *testing.T, username, message string) int { + t.Helper() + alert, err := testDB.CreateAlert(username, message) + if err != nil { + t.Fatalf("CreateAlert: %v", err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE id = ?`, alert.ID) + }) + return alert.ID +} + +// ── AlertPolice ────────────────────────────────────────────────────────────── + +func TestAlertPolice_LivreurCreatesAlert(t *testing.T) { + livreur := testUserPrefix + "alert_create_livreur" + body, _ := json.Marshal(map[string]string{"message": "Contrôle en cours"}) + c, rec := alertContext(livreur, "livreur", body, 0) + handlers.AlertPolice(c) + t.Cleanup(func() { testDB.GDB.Exec(`DELETE FROM alerte_policy WHERE username = ?`, livreur) }) + + if rec.Code != http.StatusCreated { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + AlertID int `json:"alert_id"` + User string `json:"user"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.User != livreur { + t.Errorf("user: got=%q want=%q", resp.User, livreur) + } + + alert, err := testDB.GetAlertPolicy(resp.AlertID) + if err != nil { + t.Fatalf("GetAlertPolicy: %v", err) + } + if alert.Message != "Contrôle en cours" || alert.Status != "true" { + t.Errorf("alerte créée: message=%q status=%q", alert.Message, alert.Status) + } +} + +func TestAlertPolice_NonLivreurForbidden(t *testing.T) { + for _, role := range []string{"client", "admin", "cabine"} { + t.Run(role, func(t *testing.T) { + body, _ := json.Marshal(map[string]string{"message": "test"}) + c, rec := alertContext(testUserPrefix+"alert_forbidden_"+role, role, body, 0) + handlers.AlertPolice(c) + if rec.Code != http.StatusForbidden { + t.Errorf("le rôle %q ne doit pas pouvoir déclencher une alerte police: got=%d", role, rec.Code) + } + }) + } +} + +// ── GetAlert ───────────────────────────────────────────────────────────────── + +func TestGetAlert_LivreurCanViewOwnAlert(t *testing.T) { + livreur := testUserPrefix + "alert_view_own" + alertID := createTestAlert(t, livreur, "test") + + c, rec := alertContext(livreur, "livreur", nil, alertID) + handlers.GetAlert(c) + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetAlert_LivreurCannotViewOthersAlert(t *testing.T) { + owner := testUserPrefix + "alert_view_owner" + intruder := testUserPrefix + "alert_view_intruder" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(intruder, "livreur", nil, alertID) + handlers.GetAlert(c) + if rec.Code != http.StatusForbidden { + t.Fatalf("un livreur ne doit pas pouvoir consulter l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetAlert_AdminCanViewAnyAlert(t *testing.T) { + owner := testUserPrefix + "alert_view_admin_owner" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(testUserPrefix+"alert_view_admin", "admin", nil, alertID) + handlers.GetAlert(c) + if rec.Code != http.StatusOK { + t.Fatalf("un admin doit pouvoir consulter n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +// ── EndAlert ───────────────────────────────────────────────────────────────── + +func TestEndAlert_OwnerCanEnd(t *testing.T) { + livreur := testUserPrefix + "alert_end_owner" + alertID := createTestAlert(t, livreur, "test") + + c, rec := alertContext(livreur, "livreur", nil, alertID) + handlers.EndAlert(c) + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + alert, _ := testDB.GetAlertPolicy(alertID) + if alert.Status != "false" { + t.Errorf("statut après EndAlert: got=%q want=false", alert.Status) + } +} + +func TestEndAlert_NonOwnerLivreurRejected(t *testing.T) { + owner := testUserPrefix + "alert_end_owner2" + intruder := testUserPrefix + "alert_end_intruder" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(intruder, "livreur", nil, alertID) + handlers.EndAlert(c) + if rec.Code != http.StatusForbidden { + t.Fatalf("un livreur tiers ne doit pas pouvoir terminer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String()) + } + + alert, _ := testDB.GetAlertPolicy(alertID) + if alert.Status != "true" { + t.Errorf("l'alerte ne doit pas être terminée par un intrus: got=%q want=true", alert.Status) + } +} + +// ── DeleteAlert ────────────────────────────────────────────────────────────── +// +// Corrigé : un livreur ne peut supprimer que ses propres alertes (comme +// EndAlert) ; un admin garde l'accès complet sans restriction de propriétaire. + +func TestDeleteAlert_OwnerLivreurCanDeleteOwnAlert(t *testing.T) { + owner := testUserPrefix + "alert_delete_owner_ok" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(owner, "livreur", nil, alertID) + handlers.DeleteAlert(c) + + if rec.Code != http.StatusOK { + t.Fatalf("le propriétaire doit pouvoir supprimer sa propre alerte: got=%d body=%s", rec.Code, rec.Body.String()) + } + if _, err := testDB.GetAlertPolicy(alertID); err == nil { + t.Error("l'alerte doit être supprimée") + } +} + +func TestDeleteAlert_NonOwnerLivreurRejected(t *testing.T) { + owner := testUserPrefix + "alert_delete_owner" + intruder := testUserPrefix + "alert_delete_intruder" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(intruder, "livreur", nil, alertID) + handlers.DeleteAlert(c) + + if rec.Code != http.StatusForbidden { + t.Fatalf("un livreur tiers ne doit pas pouvoir supprimer l'alerte d'un autre: got=%d body=%s", rec.Code, rec.Body.String()) + } + if _, err := testDB.GetAlertPolicy(alertID); err != nil { + t.Error("l'alerte ne doit pas être supprimée par un intrus") + } +} + +func TestDeleteAlert_AdminCanDeleteAnyAlertRegardlessOfOwner(t *testing.T) { + owner := testUserPrefix + "alert_delete_admin_owner" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(testUserPrefix+"alert_delete_admin", "admin", nil, alertID) + handlers.DeleteAlert(c) + + if rec.Code != http.StatusOK { + t.Fatalf("un admin doit pouvoir supprimer n'importe quelle alerte: got=%d body=%s", rec.Code, rec.Body.String()) + } + if _, err := testDB.GetAlertPolicy(alertID); err == nil { + t.Error("l'alerte doit être supprimée par l'admin") + } +} + +func TestDeleteAlert_NonLivreurNonAdminForbidden(t *testing.T) { + owner := testUserPrefix + "alert_delete_forbidden_owner" + alertID := createTestAlert(t, owner, "test") + + c, rec := alertContext(testUserPrefix+"alert_delete_forbidden_cabine", "cabine", nil, alertID) + handlers.DeleteAlert(c) + if rec.Code != http.StatusForbidden { + t.Errorf("le rôle cabine ne doit pas pouvoir supprimer une alerte: got=%d", rec.Code) + } +} + +// ── Listing ────────────────────────────────────────────────────────────────── + +func TestGetMyAlerts_ReturnsOnlyOwnAlerts(t *testing.T) { + mine := testUserPrefix + "alert_mine" + other := testUserPrefix + "alert_other" + createTestAlert(t, mine, "à moi 1") + createTestAlert(t, mine, "à moi 2") + createTestAlert(t, other, "pas à moi") + + c, rec := alertContext(mine, "livreur", nil, 0) + handlers.GetMyAlerts(c) + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Count int `json:"count"` + } + json.Unmarshal(rec.Body.Bytes(), &resp) + if resp.Count != 2 { + t.Errorf("nombre d'alertes du livreur: got=%d want=2", resp.Count) + } +} + +func TestGetActiveAlerts_ExcludesEndedAlerts(t *testing.T) { + livreur := testUserPrefix + "alert_active_filter" + activeID := createTestAlert(t, livreur, "active") + endedID := createTestAlert(t, livreur, "terminée") + if err := testDB.EndAlert(endedID); err != nil { + t.Fatalf("EndAlert (setup): %v", err) + } + + alerts, err := testDB.GetActiveAlerts() + if err != nil { + t.Fatalf("GetActiveAlerts: %v", err) + } + var foundActive, foundEnded bool + for _, a := range alerts { + if a.ID == activeID { + foundActive = true + } + if a.ID == endedID { + foundEnded = true + } + } + if !foundActive { + t.Error("l'alerte active doit apparaître dans GetActiveAlerts") + } + if foundEnded { + t.Error("l'alerte terminée ne doit pas apparaître dans GetActiveAlerts") + } +} diff --git a/backend/gestion/tests/commande_coverage_test.go b/backend/gestion/tests/commande_coverage_test.go new file mode 100644 index 00000000..c44eb851 --- /dev/null +++ b/backend/gestion/tests/commande_coverage_test.go @@ -0,0 +1,256 @@ +package tests + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +func cmdContext(method, username, role string, body []byte, commandID int) (*gin.Context, *httptest.ResponseRecorder) { + var reader *bytes.Reader + if body != nil { + reader = bytes.NewReader(body) + } else { + reader = bytes.NewReader([]byte{}) + } + req := httptest.NewRequest(method, "/api/v1/commands", reader) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + if username != "" { + c.Set("username", username) + } + c.Set("role", role) + if commandID != 0 { + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}} + } + return c, rec +} + +// ── CancelCommandByClient (HTTP layer) ─────────────────────────────────── + +func TestCancelCommandByClient_RejectsNonClientRole(t *testing.T) { + cleanupStockTestData(t) + c, rec := cmdContext(http.MethodPost, testUserPrefix+"cancel_role", "admin", nil, 1) + handlers.CancelCommandByClient(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role admin doit être refusé: got=%d want=403", rec.Code) + } +} + +func TestCancelCommandByClient_InvalidCommandIDReturns400(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_badid") + c, rec := cmdContext(http.MethodPost, username, "client", nil, 0) + c.Params = gin.Params{{Key: "id", Value: "not-a-number"}} + handlers.CancelCommandByClient(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code) + } +} + +func TestCancelCommandByClient_UnknownCommandReturns404(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_404") + c, rec := cmdContext(http.MethodPost, username, "client", nil, 99999999) + handlers.CancelCommandByClient(c) + if rec.Code != http.StatusNotFound { + t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCancelCommandByClient_WrongOwnerReturns403(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "cancel_owner") + intruder := newTestClient(t, "cancel_intruder") + productID := newTestProduct(t, "CancelWrongOwner", 10) + cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10) + + c, rec := cmdContext(http.MethodPost, intruder, "client", nil, cmdID) + handlers.CancelCommandByClient(c) + if rec.Code != http.StatusForbidden { + t.Errorf("un autre client ne doit pas pouvoir annuler: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCancelCommandByClient_SuccessNoPenaltyWhenNoLivreur(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_ok_nopenalty") + productID := newTestProduct(t, "CancelOkNoPenalty", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID) + handlers.CancelCommandByClient(c) + if rec.Code != http.StatusOK { + t.Fatalf("annulation sans livreur doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "cancelled" { + t.Errorf("statut après annulation: got=%s want=cancelled", got) + } +} + +func TestCancelCommandByClient_TerminalStatusReturns400(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_terminal") + productID := newTestProduct(t, "CancelTerminal", 10) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID) + handlers.CancelCommandByClient(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("annulation d'une commande livrée doit être rejetée: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCancelCommandByClient_ConfirmationRequiredReturns409WithPenaltyWarning(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_confirm") + livreur := "cancel_confirm_livreur" + productID := newTestProduct(t, "CancelConfirm", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", livreur, productID, 1, 10) + if err := testDB.SetCommandETA(cmdID, 14); err != nil { + t.Fatalf("SetCommandETA: %v", err) + } + + c, rec := cmdContext(http.MethodPost, username, "client", nil, cmdID) + handlers.CancelCommandByClient(c) + if rec.Code != http.StatusConflict { + t.Fatalf("annulation tardive sans force doit demander confirmation: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"will_apply":true`)) { + t.Errorf("la réponse doit avertir d'une pénalité à venir: body=%s", rec.Body.String()) + } + + // Statut inchangé tant que non confirmé. + if got := commandStatus(t, cmdID); got != "en_route" { + t.Errorf("statut ne doit pas changer avant confirmation: got=%s want=en_route", got) + } +} + +// ── GetMyCancellationHistory ────────────────────────────────────────────── + +func TestGetMyCancellationHistory_RejectsNonClientRole(t *testing.T) { + cleanupStockTestData(t) + c, rec := cmdContext(http.MethodGet, testUserPrefix+"hist_role", "livreur", nil, 0) + handlers.GetMyCancellationHistory(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code) + } +} + +func TestGetMyCancellationHistory_ReturnsHistoryAndTotalPenalties(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "hist_ok") + c, rec := cmdContext(http.MethodGet, username, "client", nil, 0) + handlers.GetMyCancellationHistory(c) + if rec.Code != http.StatusOK { + t.Fatalf("historique doit réussir pour un client: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"total_penalties"`)) { + t.Errorf("la réponse doit inclure total_penalties: body=%s", rec.Body.String()) + } +} + +// ── GetAllCancelledOrders ───────────────────────────────────────────────── + +func TestGetAllCancelledOrders_RejectsNonAdminNonCabine(t *testing.T) { + cleanupStockTestData(t) + c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_role", "livreur", nil, 0) + handlers.GetAllCancelledOrders(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code) + } +} + +func TestGetAllCancelledOrders_InvalidLimitFallsBackToDefault(t *testing.T) { + cleanupStockTestData(t) + c, rec := cmdContext(http.MethodGet, testUserPrefix+"allcancel_limit", "admin", nil, 0) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=not-a-number", nil) + c.Request = req + c.Set("database", testDB) + c.Set("username", testUserPrefix+"allcancel_limit") + c.Set("role", "admin") + handlers.GetAllCancelledOrders(c) + if rec.Code != http.StatusOK { + t.Fatalf("limit invalide doit quand même réussir avec un défaut: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetAllCancelledOrders_LimitCapsAt500(t *testing.T) { + cleanupStockTestData(t) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/cancelled?limit=99999", nil) + c.Set("database", testDB) + c.Set("username", testUserPrefix+"allcancel_cap") + c.Set("role", "admin") + handlers.GetAllCancelledOrders(c) + if rec.Code != http.StatusOK { + t.Fatalf("limit énorme doit quand même réussir (plafonné): got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +// ── DeleteCommandByCabine ───────────────────────────────────────────────── + +func TestDeleteCommandByCabine_RejectsNonCabineNonAdmin(t *testing.T) { + cleanupStockTestData(t) + c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_role", "client", nil, 1) + handlers.DeleteCommandByCabine(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role client doit être refusé: got=%d want=403", rec.Code) + } +} + +func TestDeleteCommandByCabine_UnknownCommandReturns404(t *testing.T) { + cleanupStockTestData(t) + c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_404", "cabine", nil, 99999999) + handlers.DeleteCommandByCabine(c) + if rec.Code != http.StatusNotFound { + t.Errorf("commande inconnue doit retourner 404: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestDeleteCommandByCabine_SuccessDeletesCommand(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delcab_ok") + productID := newTestProduct(t, "DelCabOk", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + c, rec := cmdContext(http.MethodDelete, testUserPrefix+"delcab_ok_actor", "cabine", nil, cmdID) + handlers.DeleteCommandByCabine(c) + if rec.Code != http.StatusOK { + t.Fatalf("suppression par cabine doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + var count int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count) + if count != 0 { + t.Errorf("la commande doit être supprimée: got=%d lignes restantes", count) + } +} + +// ── validateReason (testé indirectement via CancelCommandByClient) ─────── + +func TestCancelCommandByClient_BlankReasonDefaultsToStandardMessage(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reason_blank") + productID := newTestProduct(t, "ReasonBlank", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + body := []byte(`{"reason":""}`) + c, rec := cmdContext(http.MethodPost, username, "client", body, cmdID) + handlers.CancelCommandByClient(c) + if rec.Code != http.StatusOK { + t.Fatalf("annulation avec reason vide doit réussir (validateReason doit fournir un défaut, pas rejeter): got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "cancelled" { + t.Errorf("statut après annulation avec raison vide: got=%s want=cancelled", got) + } +} diff --git a/backend/gestion/tests/commands_address_proposal_test.go b/backend/gestion/tests/commands_address_proposal_test.go new file mode 100644 index 00000000..72c9e72b --- /dev/null +++ b/backend/gestion/tests/commands_address_proposal_test.go @@ -0,0 +1,210 @@ +package tests + +import "testing" + +// commandAddressState lit adresse/proposed_address/address_proposal_status +// directement pour vérifier le flux propose -> respond. +type commandAddressState struct { + Adresse string `gorm:"column:adresse"` + ProposedAddress string `gorm:"column:proposed_address"` + AddressProposalStatus string `gorm:"column:address_proposal_status"` +} + +func getCommandAddressState(t *testing.T, commandID int) commandAddressState { + t.Helper() + var s commandAddressState + if err := testDB.GDB.Raw( + `SELECT adresse, COALESCE(proposed_address, '') as proposed_address, + COALESCE(address_proposal_status, '') as address_proposal_status + FROM commandes WHERE id = ?`, commandID, + ).Scan(&s).Error; err != nil { + t.Fatalf("getCommandAddressState: %v", err) + } + return s +} + +// ── UpdateCommandAddress (modification directe admin) ─────────────────────── + +func TestUpdateCommandAddress_UpdatesAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "upd_addr_ok") + productID := newTestProduct(t, "UpdAddrOk", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + if err := testDB.UpdateCommandAddress(cmdID, "42 Nouvelle Adresse, 44000 Nantes"); err != nil { + t.Fatalf("UpdateCommandAddress: %v", err) + } + if got := getCommandAddressState(t, cmdID).Adresse; got != "42 Nouvelle Adresse, 44000 Nantes" { + t.Errorf("adresse après mise à jour: got=%q", got) + } +} + +func TestUpdateCommandAddress_RejectsEmptyAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "upd_addr_empty") + productID := newTestProduct(t, "UpdAddrEmpty", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + before := getCommandAddressState(t, cmdID).Adresse + + if err := testDB.UpdateCommandAddress(cmdID, " "); err == nil { + t.Fatal("attendu un rejet pour une adresse vide/blanche") + } + if got := getCommandAddressState(t, cmdID).Adresse; got != before { + t.Errorf("adresse ne doit pas changer sur un rejet: got=%q want=%q", got, before) + } +} + +func TestUpdateCommandAddress_RejectsUnknownCommand(t *testing.T) { + if err := testDB.UpdateCommandAddress(999999999, "1 rue inexistante"); err == nil { + t.Fatal("attendu une erreur pour une commande inexistante") + } +} + +// Note : db.UpdateCommandAddress lui-même n'interdit pas de modifier l'adresse +// d'une commande terminée — cette règle ("livre/approved/cancelled interdits") +// est uniquement appliquée par le handler HTTP (UpdateCommandAddress dans +// handlers/commands.go), pas par la fonction DB. Ce test documente ce fait +// explicitement pour qu'un futur appelant direct de la fonction DB (ex. un +// script, un worker) ne suppose pas à tort que la protection est là. +func TestUpdateCommandAddress_DBFunctionAloneDoesNotBlockTerminalStatuses(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "upd_addr_terminal") + productID := newTestProduct(t, "UpdAddrTerminal", 10) + cmdID := newTestCommandWithItem(t, username, "approved", "", productID, 1, 10) + + if err := testDB.UpdateCommandAddress(cmdID, "Adresse modifiée après coup"); err != nil { + t.Fatalf("la fonction DB seule n'impose pas la restriction de statut (attendu, voir commentaire): %v", err) + } + if got := getCommandAddressState(t, cmdID).Adresse; got != "Adresse modifiée après coup" { + t.Errorf("adresse: got=%q", got) + } +} + +// ── ProposeAddressChange / RespondToAddressProposal ───────────────────────── + +func TestProposeAddressChange_SetsProposedAddressAndPendingStatus(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "propose_addr_ok") + productID := newTestProduct(t, "ProposeAddrOk", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + + if err := testDB.ProposeAddressChange(cmdID, "Nouvelle adresse proposée, 44000 Nantes", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + s := getCommandAddressState(t, cmdID) + if s.ProposedAddress != "Nouvelle adresse proposée, 44000 Nantes" { + t.Errorf("proposed_address: got=%q", s.ProposedAddress) + } + if s.AddressProposalStatus != "pending" { + t.Errorf("address_proposal_status: got=%q want=pending", s.AddressProposalStatus) + } +} + +func TestRespondToAddressProposal_AcceptedAppliesProposedAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_accept") + productID := newTestProduct(t, "RespondAddrAccept", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée acceptée", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil { + t.Fatalf("RespondToAddressProposal (accepté): %v", err) + } + + s := getCommandAddressState(t, cmdID) + if s.Adresse != "Adresse proposée acceptée" { + t.Errorf("adresse de livraison après acceptation: got=%q want=%q", s.Adresse, "Adresse proposée acceptée") + } + if s.ProposedAddress != "" { + t.Errorf("proposed_address doit être vidé après réponse: got=%q", s.ProposedAddress) + } + if s.AddressProposalStatus != "accepted" { + t.Errorf("address_proposal_status: got=%q want=accepted", s.AddressProposalStatus) + } +} + +func TestRespondToAddressProposal_RejectedKeepsOriginalAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_reject") + productID := newTestProduct(t, "RespondAddrReject", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + original := getCommandAddressState(t, cmdID).Adresse + + if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée refusée", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + if err := testDB.RespondToAddressProposal(cmdID, username, false); err != nil { + t.Fatalf("RespondToAddressProposal (refusé): %v", err) + } + + s := getCommandAddressState(t, cmdID) + if s.Adresse != original { + t.Errorf("l'adresse de livraison ne doit pas changer sur un refus: got=%q want=%q", s.Adresse, original) + } + if s.ProposedAddress != "" { + t.Errorf("proposed_address doit être vidé même en cas de refus: got=%q", s.ProposedAddress) + } + if s.AddressProposalStatus != "rejected" { + t.Errorf("address_proposal_status: got=%q want=rejected", s.AddressProposalStatus) + } +} + +func TestRespondToAddressProposal_FailsWhenNoProposalPending(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_none") + productID := newTestProduct(t, "RespondAddrNone", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + + if err := testDB.RespondToAddressProposal(cmdID, username, true); err == nil { + t.Fatal("attendu une erreur : aucune proposition en attente") + } +} + +// La proposition est liée au client propriétaire de la commande : un autre +// client ne doit pas pouvoir y répondre à sa place. +func TestRespondToAddressProposal_WrongClientCannotRespond(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "respond_addr_owner") + intruder := newTestClient(t, "respond_addr_intruder") + productID := newTestProduct(t, "RespondAddrIntruder", 10) + cmdID := newTestCommandWithItem(t, owner, "assigned", "", productID, 1, 10) + if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + if err := testDB.RespondToAddressProposal(cmdID, intruder, true); err == nil { + t.Fatal("un client tiers ne doit pas pouvoir répondre à la proposition d'un autre client") + } + + s := getCommandAddressState(t, cmdID) + if s.AddressProposalStatus != "pending" { + t.Errorf("la proposition doit rester en attente après une tentative d'un intrus: got=%q want=pending", s.AddressProposalStatus) + } +} + +// Rejeu (double-tap) : une fois traitée, la même proposition ne doit pas +// pouvoir être acceptée/refusée une seconde fois. +func TestRespondToAddressProposal_DoubleRespondFails(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "respond_addr_double") + productID := newTestProduct(t, "RespondAddrDouble", 10) + cmdID := newTestCommandWithItem(t, username, "assigned", "", productID, 1, 10) + if err := testDB.ProposeAddressChange(cmdID, "Adresse proposée", "admin_test"); err != nil { + t.Fatalf("ProposeAddressChange: %v", err) + } + + if err := testDB.RespondToAddressProposal(cmdID, username, true); err != nil { + t.Fatalf("1ère réponse: %v", err) + } + if err := testDB.RespondToAddressProposal(cmdID, username, false); err == nil { + t.Fatal("une 2e réponse sur une proposition déjà traitée doit échouer") + } + + // La 2e tentative (rejet) ne doit pas être appliquée par-dessus la 1ère (acceptation). + if got := getCommandAddressState(t, cmdID).AddressProposalStatus; got != "accepted" { + t.Errorf("le statut doit rester celui de la 1ère réponse: got=%q want=accepted", got) + } +} diff --git a/backend/gestion/tests/commands_address_test.go b/backend/gestion/tests/commands_address_test.go new file mode 100644 index 00000000..dba6bbc0 --- /dev/null +++ b/backend/gestion/tests/commands_address_test.go @@ -0,0 +1,227 @@ +package tests + +import ( + "fmt" + "strconv" + "testing" +) + +// Adresses réalistes de Nantes (44000) utilisées pour vérifier que l'adresse +// de livraison survit intacte à la création puis à toutes les voies de +// récupération d'une commande (client, admin, livreur, historique). +var nantesAddresses = []string{ + "12 Rue Crébillon, 44000 Nantes", + "3 Place Royale, 44000 Nantes", + "5 Cours des 50 Otages, 44000 Nantes", + "8 Rue de Verdun, 44000 Nantes", +} + +// newTestCommandWithAddress crée directement une commande avec une adresse et +// un statut contrôlés (en contournant le checkout), pour tester isolément la +// récupération de l'adresse par les différentes fonctions de listing. +func newTestCommandWithAddress(t *testing.T, username, status, address, livreurAssign string, productID int, quantite, prix float64) int { + t.Helper() + var cmdID int + if err := testDB.GDB.Raw( + `INSERT INTO commandes (username, status, adresse, livreur_assign, total_prix, created_at, updated_at) + VALUES (?, ?, ?, NULLIF(?, ''), ?, NOW(), NOW()) RETURNING id`, + username, status, address, livreurAssign, prix, + ).Scan(&cmdID).Error; err != nil { + t.Fatalf("création commande test avec adresse: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) + VALUES (?, ?, 'item test', ?, ?, 'pending')`, + cmdID, productID, quantite, prix, + ).Error; err != nil { + t.Fatalf("création item test: %v", err) + } + return cmdID +} + +// Le checkout réel (panier -> CreateCommandWithAddress) doit stocker l'adresse +// telle quelle, et GetCommandByID doit la restituer à l'identique. +func TestCreateCommandWithAddress_RoundTripsRealNantesAddress(t *testing.T) { + for _, address := range nantesAddresses { + t.Run(address, func(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "addr_checkout") + productID := newTestProduct(t, "AddrCheckout", 10) + + if _, err := testDB.AddToBasket(username, productID, 1); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + cmd, err := testDB.CreateCommandWithAddress(username, address) + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + command, err := testDB.GetCommandByID(cmd.ID) + if err != nil { + t.Fatalf("GetCommandByID: %v", err) + } + got, _ := command["adresse"].(string) + if got != address { + t.Errorf("adresse récupérée: got=%q want=%q", got, address) + } + if got == "" { + t.Error("l'adresse ne doit jamais être vide") + } + }) + } +} + +// Une adresse vide ou uniquement composée d'espaces doit être rejetée au +// checkout — pas de commande créée avec une adresse de livraison absente. +func TestCreateCommandWithAddress_RejectsEmptyOrBlankAddress(t *testing.T) { + for _, address := range []string{"", " ", "\t\n"} { + cleanupStockTestData(t) + username := newTestClient(t, "addr_blank") + productID := newTestProduct(t, "AddrBlank", 10) + + if _, err := testDB.AddToBasket(username, productID, 1); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + if _, err := testDB.CreateCommandWithAddress(username, address); err == nil { + t.Errorf("adresse %q aurait dû être rejetée", address) + } + + var count int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&count) + if count != 0 { + t.Errorf("aucune commande ne doit être créée avec une adresse %q: got=%d", address, count) + } + } +} + +// GetAllCommands (vue admin) doit toujours renvoyer l'adresse de chaque +// commande, quel que soit son statut. +func TestGetAllCommands_AlwaysIncludesAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "addr_admin_list") + productID := newTestProduct(t, "AddrAdminList", 10) + + want := map[int]string{} + for i, address := range nantesAddresses { + status := []string{"pending", "assigned", "en_route", "livre"}[i%4] + cmdID := newTestCommandWithAddress(t, username, status, address, "", productID, 1, 10) + want[cmdID] = address + } + + commands, err := testDB.GetAllCommands("", username) + if err != nil { + t.Fatalf("GetAllCommands: %v", err) + } + if len(commands) != len(want) { + t.Fatalf("nombre de commandes: got=%d want=%d", len(commands), len(want)) + } + for _, c := range commands { + id, _ := c["id"].(int) + address, _ := c["adresse"].(string) + if address == "" { + t.Errorf("commande %d: adresse vide", id) + } + if want[id] != address { + t.Errorf("commande %d: adresse=%q want=%q", id, address, want[id]) + } + } +} + +// GetDeliveryPersonCommands (vue livreur) doit inclure l'adresse de chaque +// commande qui lui est assignée. +func TestGetDeliveryPersonCommands_IncludesAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "addr_livreur_client") + productID := newTestProduct(t, "AddrLivreur", 10) + livreurUsername := testUserPrefix + "addr_livreur" + address := nantesAddresses[0] + + cmdID := newTestCommandWithAddress(t, username, "assigned", address, livreurUsername, productID, 1, 10) + + commands, err := testDB.GetDeliveryPersonCommands(livreurUsername, "") + if err != nil { + t.Fatalf("GetDeliveryPersonCommands: %v", err) + } + if len(commands) != 1 { + t.Fatalf("nombre de commandes assignées: got=%d want=1", len(commands)) + } + got, _ := commands[0]["adresse"].(string) + if got != address { + t.Errorf("adresse: got=%q want=%q", got, address) + } + // Le type Go concret de la colonne "id" issue d'un Raw(...).Scan(&[]map[string]any) + // n'est pas garanti (int64 selon le driver) — même précaution que le code + // de production (ex: handlers/deleviry.go, fmt.Sprintf + strconv.Atoi). + id, _ := strconv.Atoi(fmt.Sprintf("%v", commands[0]["id"])) + if id != cmdID { + t.Errorf("id de commande inattendu: got=%d want=%d", id, cmdID) + } +} + +// GetCancelledCommands doit inclure l'adresse même pour une commande annulée. +func TestGetCancelledCommands_IncludesAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "addr_cancelled") + productID := newTestProduct(t, "AddrCancelled", 10) + address := nantesAddresses[1] + + newTestCommandWithAddress(t, username, "cancelled", address, "", productID, 1, 10) + + commands, err := testDB.GetCancelledCommands(username, 10) + if err != nil { + t.Fatalf("GetCancelledCommands: %v", err) + } + if len(commands) != 1 { + t.Fatalf("nombre de commandes annulées: got=%d want=1", len(commands)) + } + got, _ := commands[0]["adresse"].(string) + if got != address { + t.Errorf("adresse: got=%q want=%q", got, address) + } +} + +// GetCompletedCommandsByUsername (historique client) doit inclure l'adresse +// des commandes terminées (approved). +func TestGetCompletedCommandsByUsername_IncludesAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "addr_history") + productID := newTestProduct(t, "AddrHistory", 10) + address := nantesAddresses[2] + + newTestCommandWithAddress(t, username, "approved", address, "", productID, 1, 10) + + commands, err := testDB.GetCompletedCommandsByUsername(username) + if err != nil { + t.Fatalf("GetCompletedCommandsByUsername: %v", err) + } + if len(commands) != 1 { + t.Fatalf("nombre de commandes terminées: got=%d want=1", len(commands)) + } + got, _ := commands[0]["adresse"].(string) + if got != address { + t.Errorf("adresse: got=%q want=%q", got, address) + } +} + +// GetAllCommandsOldestFirst (vue cabine/admin triée) doit également inclure +// l'adresse de chaque commande. +func TestGetAllCommandsOldestFirst_IncludesAddress(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "addr_oldest_first") + productID := newTestProduct(t, "AddrOldestFirst", 10) + address := nantesAddresses[3] + + newTestCommandWithAddress(t, username, "pending", address, "", productID, 1, 10) + + commands, err := testDB.GetAllCommandsOldestFirst("", username) + if err != nil { + t.Fatalf("GetAllCommandsOldestFirst: %v", err) + } + if len(commands) != 1 { + t.Fatalf("nombre de commandes: got=%d want=1", len(commands)) + } + got, _ := commands[0]["adresse"].(string) + if got != address { + t.Errorf("adresse: got=%q want=%q", got, address) + } +} diff --git a/backend/gestion/tests/commands_admin_cancel_test.go b/backend/gestion/tests/commands_admin_cancel_test.go new file mode 100644 index 00000000..897e2dd1 --- /dev/null +++ b/backend/gestion/tests/commands_admin_cancel_test.go @@ -0,0 +1,124 @@ +package tests + +import ( + "bytes" + "encoding/json" + "gestion/handlers" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "github.com/gin-gonic/gin" +) + +// Ce fichier teste UpdateCommandStatusAdmin (annulation par admin/cabine). +// Ce chemin utilisait auparavant deux appels séparés (lecture du statut, puis +// restauration du stock hors transaction) — un double-tap ou appel concurrent +// pouvait alors rembourser le stock deux fois. Il délègue maintenant à +// db.CancelCommandByAdminAtomic, qui verrouille la commande (FOR UPDATE) et +// fait remboursement + changement de statut dans une seule transaction, +// comme CancelCommandAtomic (client) et CancelDeliveryByLivreurAtomic (livreur). + +func adminCancelContext(commandID int) (*gin.Context, *httptest.ResponseRecorder) { + body, _ := json.Marshal(map[string]string{"status": "cancelled"}) + req := httptest.NewRequest(http.MethodPut, "/api/v2/admin/protected/orders/x/status", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("role", "admin") + c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}} + return c, rec +} + +func TestUpdateCommandStatusAdmin_RefundsStockOnCancel(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "admincancel_single") + productID := newTestProduct(t, "AdminCancelSingle", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) + + c, rec := adminCancelContext(cmdID) + handlers.UpdateCommandStatusAdmin(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := productStock(t, productID); got != 8 { + t.Errorf("stock après annulation admin (5 initial + 3 remboursés): got=%.2f want=8", got) + } +} + +// Statut déjà terminal (livré) : la commande peut toujours être basculée en +// "cancelled" par l'admin (correction), mais le stock ne doit pas être +// remboursé une seconde fois puisqu'il a déjà quitté l'entrepôt. +func TestUpdateCommandStatusAdmin_DoesNotRefundAlreadyDeliveredOrder(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "admincancel_livre") + productID := newTestProduct(t, "AdminCancelLivre", 5) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 3, 30) + + c, rec := adminCancelContext(cmdID) + handlers.UpdateCommandStatusAdmin(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := productStock(t, productID); got != 5 { + t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got) + } +} + +// Double-tap / retry réseau sur le bouton "annuler" côté admin : régression +// du bug corrigé (double remboursement). Barrière pour maximiser le +// recouvrement réel entre goroutines. +func TestUpdateCommandStatusAdmin_ConcurrentCancelDoesNotDoubleRefundStock(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "admincancel_concurrent") + productID := newTestProduct(t, "AdminCancelConcurrent", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) + + n := 10 + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < n; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + c, _ := adminCancelContext(cmdID) + handlers.UpdateCommandStatusAdmin(c) + }() + } + close(start) + wg.Wait() + + if got := productStock(t, productID); got != 8 { + t.Errorf("stock après %d annulations admin concurrentes de la même commande (5 initial + 3 remboursés une seule fois attendu): got=%.2f", n, got) + } +} + +// Reproduction déterministe de l'ancienne fenêtre de course : deux appels +// annulent la même commande l'un juste après l'autre (simulation d'un +// double-tap sans dépendre du timing du scheduler). Avec CancelCommandByAdmin +// Atomic, le second appel voit la commande déjà 'cancelled' sous verrou et ne +// rembourse pas une seconde fois. +func TestUpdateCommandStatusAdmin_SequentialDoubleCancelDoesNotDoubleRefund(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "admincancel_sequential") + productID := newTestProduct(t, "AdminCancelSequential", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) + + if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil { + t.Fatalf("1er appel: %v", err) + } + if err := testDB.CancelCommandByAdminAtomic(cmdID); err != nil { + t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err) + } + + if got := productStock(t, productID); got != 8 { + t.Errorf("stock après double annulation admin: got=%.2f want=8 (un seul remboursement)", got) + } +} diff --git a/backend/gestion/tests/commands_handlers_coverage_test.go b/backend/gestion/tests/commands_handlers_coverage_test.go new file mode 100644 index 00000000..23ef2091 --- /dev/null +++ b/backend/gestion/tests/commands_handlers_coverage_test.go @@ -0,0 +1,222 @@ +package tests + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +// newTestLivreur crée un utilisateur role=livreur réel (AssignDeliveryPerson +// vérifie son existence/rôle en base, pas juste le contexte gin) et +// programme son nettoyage. +func newTestLivreur(t *testing.T, name string) string { + t.Helper() + username := testUserPrefix + name + if err := testDB.GDB.Exec( + `INSERT INTO users (username, password, role) VALUES (?, 'x', 'livreur') ON CONFLICT (username) DO NOTHING`, + username, + ).Error; err != nil { + t.Fatalf("création livreur test %q: %v", username, err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM users WHERE username = ?`, username) + }) + return username +} + +func getAllCommandsContext(role string, query url.Values) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands?"+query.Encode(), nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", testUserPrefix+"gac_admin") + c.Set("role", role) + return c, rec +} + +// ── GetAllCommands ──────────────────────────────────────────────────────── + +func TestGetAllCommands_RejectsNonAdminNonCabine(t *testing.T) { + cleanupStockTestData(t) + c, rec := getAllCommandsContext("livreur", url.Values{}) + handlers.GetAllCommands(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code) + } +} + +func TestGetAllCommands_FiltersByStatusAndUsername(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "gac_filter") + productID := newTestProduct(t, "GACFilter", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + otherUser := newTestClient(t, "gac_filter_other") + newTestCommandWithItem(t, otherUser, "cancelled", "", productID, 1, 10) + + q := url.Values{} + q.Set("status", "pending") + q.Set("username", username) + c, rec := getAllCommandsContext("admin", q) + handlers.GetAllCommands(c) + if rec.Code != http.StatusOK { + t.Fatalf("requête filtrée doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(fmt.Sprintf(`"id":%d`, cmdID))) { + t.Errorf("la commande filtrée doit apparaître dans le résultat: body=%s", rec.Body.String()) + } +} + +func TestGetAllCommands_AllSentinelReturnsEverything(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "gac_all") + productID := newTestProduct(t, "GACAll", 10) + newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/commands/all/all", nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", testUserPrefix+"gac_admin") + c.Set("role", "admin") + c.Params = gin.Params{{Key: "status", Value: "all"}, {Key: "username", Value: "all"}} + handlers.GetAllCommands(c) + if rec.Code != http.StatusOK { + t.Fatalf("sentinel 'all' doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +// ── AssignDeliveryPerson ────────────────────────────────────────────────── + +func assignContext(role string, body []byte, commandID int, livreurUsername string) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/assign", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("geoService", ensureTestGeoService()) + c.Set("username", testUserPrefix+"assign_staff") + c.Set("role", role) + params := gin.Params{{Key: "command_id", Value: fmt.Sprintf("%d", commandID)}} + if livreurUsername != "" { + params = append(params, gin.Param{Key: "username", Value: livreurUsername}) + } + c.Params = params + return c, rec +} + +func TestAssignDeliveryPerson_RejectsNonAdminNonCabine(t *testing.T) { + cleanupStockTestData(t) + c, rec := assignContext("client", nil, 1, "someone") + handlers.AssignDeliveryPerson(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role client doit être refusé: got=%d want=403", rec.Code) + } +} + +func TestAssignDeliveryPerson_SupportsCommandIDParam(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "assign_cmdid") + livreur := newTestLivreur(t, "assign_cmdid_livreur") + productID := newTestProduct(t, "AssignCmdID", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + c, rec := assignContext("admin", nil, cmdID, livreur) + handlers.AssignDeliveryPerson(c) + if rec.Code != http.StatusOK { + t.Fatalf("assignation via :command_id doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestAssignDeliveryPerson_SupportsIDParamFallback(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "assign_id") + livreur := newTestLivreur(t, "assign_id_livreur") + productID := newTestProduct(t, "AssignID", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/cabine/assign", bytes.NewReader([]byte(fmt.Sprintf(`{"livreur_username":%q}`, livreur)))) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("geoService", ensureTestGeoService()) + c.Set("username", testUserPrefix+"assign_staff") + c.Set("role", "cabine") + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", cmdID)}} + handlers.AssignDeliveryPerson(c) + if rec.Code != http.StatusOK { + t.Fatalf("assignation via :id (fallback cabine) doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +// ── GetCommandItemsWithDetails ──────────────────────────────────────────── + +func itemsDetailedContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/items", nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + if setUsername { + c.Set("username", username) + } + c.Set("role", role) + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}} + return c, rec +} + +func TestGetCommandItemsWithDetails_UnauthenticatedReturns401(t *testing.T) { + cleanupStockTestData(t) + c, rec := itemsDetailedContext("", "client", 1, false) + handlers.GetCommandItemsWithDetails(c) + if rec.Code != http.StatusUnauthorized { + t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code) + } +} + +func TestGetCommandItemsWithDetails_IDORBlockedForOtherClient(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "items_owner") + intruder := newTestClient(t, "items_intruder") + productID := newTestProduct(t, "ItemsIDOR", 10) + cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10) + + c, rec := itemsDetailedContext(intruder, "client", cmdID, true) + handlers.GetCommandItemsWithDetails(c) + if rec.Code != http.StatusForbidden { + t.Errorf("un autre client ne doit pas accéder aux items: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetCommandItemsWithDetails_OwnerCanAccess(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "items_owner_ok") + productID := newTestProduct(t, "ItemsOwnerOk", 10) + cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 2, 20) + + c, rec := itemsDetailedContext(owner, "client", cmdID, true) + handlers.GetCommandItemsWithDetails(c) + if rec.Code != http.StatusOK { + t.Fatalf("le propriétaire doit accéder à ses items: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetCommandItemsWithDetails_NoItemsReturns404(t *testing.T) { + cleanupStockTestData(t) + c, rec := itemsDetailedContext(testUserPrefix+"items_admin", "admin", 99999999, true) + handlers.GetCommandItemsWithDetails(c) + if rec.Code != http.StatusNotFound { + t.Errorf("commande sans item doit retourner 404: got=%d", rec.Code) + } +} diff --git a/backend/gestion/tests/deleviry_gps_test.go b/backend/gestion/tests/deleviry_gps_test.go new file mode 100644 index 00000000..ebee46a0 --- /dev/null +++ b/backend/gestion/tests/deleviry_gps_test.go @@ -0,0 +1,188 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +// UpdateDeliveryStatus (handlers/deleviry.go) refuse de valider une livraison +// (statut "livre") si le livreur se trouve à plus de 350m de la destination +// (contrôle anti-fraude — seuil relevé de 100m à 350m à la demande explicite, +// pour tolérer l'imprécision GPS réelle en zone urbaine/immeuble). + +const earthRadiusMeters = 6371000.0 + +// destinationPointNorthOf renvoie un point situé à "meters" au nord de +// (lat, lon) — même longitude, donc distance ≈ purement le delta de latitude +// (formule identique à utils.CalculateDistance pour ce cas particulier). +func destinationPointNorthOf(lat, lon, meters float64) (float64, float64) { + latOffsetRad := meters / earthRadiusMeters + latOffsetDeg := latOffsetRad * (180 / math.Pi) + return lat + latOffsetDeg, lon +} + +func setCommandDestination(t *testing.T, commandID int, lat, lon float64) { + t.Helper() + if err := testDB.GDB.Exec( + `UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?`, + lat, lon, commandID, + ).Error; err != nil { + t.Fatalf("setCommandDestination: %v", err) + } +} + +// deliveryStatusContextJSON est la variante de deliveryStatusContext (voir +// penalty_test.go) qui accepte un corps JSON arbitraire — nécessaire ici pour +// pouvoir passer latitude/longitude, que l'helper existant ne supporte pas. +func deliveryStatusContextJSON(username string, commandID int, body []byte) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPut, fmt.Sprintf("/api/v1/livreur/deliveries/%d/status", commandID), bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}} + c.Set("database", testDB) + c.Set("username", username) + c.Set("role", "livreur") + return c, rec +} + +const nantesLat, nantesLon = 47.2184, -1.5536 + +func TestUpdateDeliveryStatus_GPS_WithinThresholdValidatesDelivery(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_within") + client := newTestClient(t, "gps_client_within") + productID := newTestProduct(t, "GPSWithin", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 200) // 200m < 350m + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon}) + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "livre" { + t.Errorf("statut après validation à 200m: got=%s want=livre", got) + } +} + +func TestUpdateDeliveryStatus_GPS_BeyondThresholdRejectsValidation(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_beyond") + client := newTestClient(t, "gps_client_beyond") + productID := newTestProduct(t, "GPSBeyond", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 400) // 400m > 350m + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon}) + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "en_route" { + t.Errorf("le statut ne doit pas passer à 'livre' au-delà de 350m: got=%s want=en_route", got) + } +} + +// Preuve directe du changement demandé : une distance de 150m, qui aurait +// échoué sous l'ancien seuil de 100m, doit maintenant réussir sous 350m. +func TestUpdateDeliveryStatus_GPS_150Meters_PassesUnderNewThreshold(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_150m") + client := newTestClient(t, "gps_client_150m") + productID := newTestProduct(t, "GPS150m", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + livreurLat, livreurLon := destinationPointNorthOf(nantesLat, nantesLon, 150) + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": livreurLat, "longitude": livreurLon}) + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("150m doit être accepté sous le nouveau seuil de 350m: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "livre" { + t.Errorf("statut après validation à 150m: got=%s want=livre", got) + } +} + +func TestUpdateDeliveryStatus_GPS_MissingCoordinatesRejected(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_missing_coords") + client := newTestClient(t, "gps_client_missing_coords") + productID := newTestProduct(t, "GPSMissingCoords", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + body, _ := json.Marshal(map[string]any{"status": "livre"}) // latitude/longitude absents (zéro) + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status HTTP sans coordonnées: got=%d want=%d body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "en_route" { + t.Errorf("le statut ne doit pas changer sans coordonnées GPS: got=%s want=en_route", got) + } +} + +// Si la commande n'a pas de coordonnées de destination enregistrées (adresse +// non géocodée), la validation GPS est ignorée plutôt que de bloquer le +// livreur indéfiniment. +func TestUpdateDeliveryStatus_GPS_MissingDestinationCoordinatesSkipsValidation(t *testing.T) { + cleanupStockTestData(t) + livreur := newTestClient(t, "gps_livreur_no_dest") + client := newTestClient(t, "gps_client_no_dest") + productID := newTestProduct(t, "GPSNoDest", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", livreur, productID, 1, 10) + // Pas d'appel à setCommandDestination : dest_latitude/dest_longitude restent à 0/NULL. + + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": 48.8566, "longitude": 2.3522}) // Paris, sans rapport + c, rec := deliveryStatusContextJSON(livreur, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("sans destination enregistrée, la validation GPS doit être ignorée: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "livre" { + t.Errorf("statut: got=%s want=livre", got) + } +} + +func TestUpdateDeliveryStatus_RejectsWhenNotAssignedToThisLivreur(t *testing.T) { + cleanupStockTestData(t) + assignedLivreur := newTestClient(t, "gps_assigned_livreur") + intruder := newTestClient(t, "gps_intruder_livreur") + client := newTestClient(t, "gps_client_wrong_livreur") + productID := newTestProduct(t, "GPSWrongLivreur", 10) + cmdID := newTestCommandWithItem(t, client, "en_route", assignedLivreur, productID, 1, 10) + setCommandDestination(t, cmdID, nantesLat, nantesLon) + + body, _ := json.Marshal(map[string]any{"status": "livre", "latitude": nantesLat, "longitude": nantesLon}) + c, rec := deliveryStatusContextJSON(intruder, cmdID, body) + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusForbidden { + t.Fatalf("un livreur non assigné doit être rejeté: got=%d want=%d body=%s", rec.Code, http.StatusForbidden, rec.Body.String()) + } + if got := commandStatus(t, cmdID); got != "en_route" { + t.Errorf("le statut ne doit pas changer: got=%s want=en_route", got) + } +} diff --git a/backend/gestion/tests/delivery_mode_test.go b/backend/gestion/tests/delivery_mode_test.go new file mode 100644 index 00000000..5e51c076 --- /dev/null +++ b/backend/gestion/tests/delivery_mode_test.go @@ -0,0 +1,261 @@ +package tests + +import ( + "encoding/json" + "gestion/db" + "gestion/models" + "testing" + "time" +) + +// newTestProductWithCategory crée un produit de test avec une catégorie +// personnalisée (contrairement à newTestProduct qui pose toujours "test") — +// nécessaire ici pour distinguer les catégories dans le routage par livreur. +func newTestProductWithCategory(t *testing.T, name, category string, stock float64) int { + t.Helper() + fullName := testProductPrefix + name + var id int + if err := testDB.GDB.Raw( + `INSERT INTO products (name, category, description, stock) VALUES (?, ?, '', ?) RETURNING id`, + fullName, category, stock, + ).Scan(&id).Error; err != nil { + t.Fatalf("création produit test %q: %v", fullName, err) + } + if err := testDB.GDB.Exec( + `INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`, + id, + ).Error; err != nil { + t.Fatalf("création prix produit test %q: %v", fullName, err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id) + testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id) + }) + return id +} + +// setLivreurStatus place directement en Redis le statut d'un livreur, comme +// le ferait l'app livreur en production (clé "delivery:status:{username}"). +func setLivreurStatus(t *testing.T, username, status string) { + t.Helper() + data, err := json.Marshal(models.DeliveryPersonStatus{ + Username: username, + Status: status, + LastUpdate: time.Now(), + }) + if err != nil { + t.Fatalf("marshal DeliveryPersonStatus: %v", err) + } + key := "delivery:status:" + username + if err := db.Redis.Set(db.RedisCtx, key, data, 0).Err(); err != nil { + t.Fatalf("setLivreurStatus: %v", err) + } + t.Cleanup(func() { + db.Redis.Del(db.RedisCtx, key) + }) +} + +func setDeliveryModeSettings(t *testing.T, mode models.DeliveryModeConfig) { + t.Helper() + data, err := json.Marshal(mode) + if err != nil { + t.Fatalf("marshal delivery_mode: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO app_settings (key, value) VALUES ('delivery_mode', ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + string(data), + ).Error; err != nil { + t.Fatalf("setDeliveryModeSettings: %v", err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'delivery_mode'`) + }) +} + +func containsUsername(list []string, username string) bool { + for _, u := range list { + if u == username { + return true + } + } + return false +} + +// ── GetCommandCategories ───────────────────────────────────────────────────── + +func TestGetCommandCategories_ReturnsDistinctProductCategories(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_categories") + productA := newTestProductWithCategory(t, "CatA", "cat_a", 10) + productB := newTestProductWithCategory(t, "CatB", "cat_b", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10) + testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`, + cmdID, productB, + ) + + categories, err := testDB.GetCommandCategories(cmdID) + if err != nil { + t.Fatalf("GetCommandCategories: %v", err) + } + if len(categories) != 2 || !containsUsername(categories, "cat_a") || !containsUsername(categories, "cat_b") { + t.Errorf("catégories: got=%v want=[cat_a cat_b]", categories) + } +} + +// ── GetEligibleDeliverymenForCommand ───────────────────────────────────────── + +func TestGetEligibleDeliverymenForCommand_SingleModeReturnsAllActive(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_single") + productID := newTestProductWithCategory(t, "Single", "cat_a", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + livreurA := testUserPrefix + "delivmode_single_a" + livreurB := testUserPrefix + "delivmode_single_b" + setLivreurStatus(t, livreurA, "available") + setLivreurStatus(t, livreurB, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"}) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) { + t.Errorf("mode single doit renvoyer tous les livreurs actifs: got=%v", eligible) + } +} + +func TestGetEligibleDeliverymenForCommand_CategoryBasedFiltersToMatchingRoute(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_filter") + productID := newTestProductWithCategory(t, "Filter", "cat_a", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + livreurA := testUserPrefix + "delivmode_filter_a" + livreurB := testUserPrefix + "delivmode_filter_b" + setLivreurStatus(t, livreurA, "available") + setLivreurStatus(t, livreurB, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{ + Mode: "category_based", + CategoryRoutes: []models.CategoryRoute{ + {DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, + {DeliverymanUsername: livreurB, Categories: []string{"cat_b"}}, + }, + }) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) { + t.Errorf("livreurA (cat_a) doit être éligible: got=%v", eligible) + } + if containsUsername(eligible, livreurB) { + t.Errorf("livreurB (cat_b, non commandée) ne doit pas être éligible: got=%v", eligible) + } +} + +// Cas limite documenté explicitement dans le modèle métier : une commande +// mixte (catégories relevant de livreurs différents) doit renvoyer l'UNION +// des livreurs éligibles, pas une intersection (aucun livreur unique ne gère +// forcément toutes les catégories à la fois). +func TestGetEligibleDeliverymenForCommand_MixedCategoryCommand_ReturnsUnion(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_mixed") + productA := newTestProductWithCategory(t, "MixedA", "cat_a", 10) + productB := newTestProductWithCategory(t, "MixedB", "cat_b", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productA, 1, 10) + testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) VALUES (?, ?, 'item2', 1, 10, 'pending')`, + cmdID, productB, + ) + + livreurA := testUserPrefix + "delivmode_mixed_a" + livreurB := testUserPrefix + "delivmode_mixed_b" + setLivreurStatus(t, livreurA, "available") + setLivreurStatus(t, livreurB, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{ + Mode: "category_based", + CategoryRoutes: []models.CategoryRoute{ + {DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, + {DeliverymanUsername: livreurB, Categories: []string{"cat_b"}}, + }, + }) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) || !containsUsername(eligible, livreurB) { + t.Errorf("commande mixte cat_a+cat_b doit renvoyer l'union des deux livreurs: got=%v", eligible) + } +} + +func TestGetEligibleDeliverymenForCommand_NoRouteMatchesFallsBackToAllActive(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_nomatch") + productID := newTestProductWithCategory(t, "NoMatch", "cat_c", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + livreurA := testUserPrefix + "delivmode_nomatch_a" + setLivreurStatus(t, livreurA, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{ + Mode: "category_based", + CategoryRoutes: []models.CategoryRoute{ + {DeliverymanUsername: livreurA, Categories: []string{"cat_a"}}, // ne couvre pas cat_c + }, + }) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) { + t.Errorf("aucune route ne couvre cat_c -> repli sur tous les livreurs actifs: got=%v", eligible) + } +} + +func TestGetEligibleDeliverymenForCommand_EmptyCategoryRoutesFallsBackToAllActive(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_emptyroutes") + productID := newTestProductWithCategory(t, "EmptyRoutes", "cat_a", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + livreurA := testUserPrefix + "delivmode_emptyroutes_a" + setLivreurStatus(t, livreurA, "available") + setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "category_based", CategoryRoutes: []models.CategoryRoute{}}) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if !containsUsername(eligible, livreurA) { + t.Errorf("category_based sans route configurée -> repli sur tous les livreurs actifs: got=%v", eligible) + } +} + +func TestGetEligibleDeliverymenForCommand_OfflineLivreurNeverEligible(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delivmode_offline") + productID := newTestProductWithCategory(t, "Offline", "cat_a", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + onlineLivreur := testUserPrefix + "delivmode_offline_online" + offlineLivreur := testUserPrefix + "delivmode_offline_offline" + setLivreurStatus(t, onlineLivreur, "available") + setLivreurStatus(t, offlineLivreur, "offline") + setDeliveryModeSettings(t, models.DeliveryModeConfig{Mode: "single"}) + + eligible, err := testDB.GetEligibleDeliverymenForCommand(cmdID) + if err != nil { + t.Fatalf("GetEligibleDeliverymenForCommand: %v", err) + } + if containsUsername(eligible, offlineLivreur) { + t.Errorf("un livreur offline ne doit jamais être éligible: got=%v", eligible) + } + if !containsUsername(eligible, onlineLivreur) { + t.Errorf("le livreur en ligne doit être éligible: got=%v", eligible) + } +} diff --git a/backend/gestion/tests/eta_coverage_test.go b/backend/gestion/tests/eta_coverage_test.go new file mode 100644 index 00000000..63fcc28c --- /dev/null +++ b/backend/gestion/tests/eta_coverage_test.go @@ -0,0 +1,194 @@ +package tests + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "gestion/db" + "gestion/handlers" + "gestion/services" + + "github.com/gin-gonic/gin" +) + +// db.Redis n'est initialisé qu'après TestMain (db.InitRedis) — un var +// package-level serait construit trop tôt, d'où cette init paresseuse. +var testGeoService *services.GeoService + +// ensureTestGeoService initialise paresseusement le GeoService partagé +// (db.Redis n'existe qu'après TestMain) — réutilisé par les autres fichiers +// de tests qui ont besoin de "geoService" dans le contexte gin. +func ensureTestGeoService() *services.GeoService { + if testGeoService == nil { + testGeoService = services.NewGeoService(db.Redis, db.RedisCtx) + } + return testGeoService +} + +func etaContext(username, role string, commandID int, setUsername bool) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/eta", bytes.NewReader(nil)) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("geoService", ensureTestGeoService()) + if setUsername { + c.Set("username", username) + } + c.Set("role", role) + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", commandID)}} + return c, rec +} + +func TestGetOrderETA_UnauthenticatedReturns401(t *testing.T) { + cleanupStockTestData(t) + c, rec := etaContext("", "client", 1, false) + handlers.GetOrderETA(c) + if rec.Code != http.StatusUnauthorized { + t.Errorf("non authentifié doit retourner 401: got=%d", rec.Code) + } +} + +func TestGetOrderETA_InvalidCommandIDReturns400(t *testing.T) { + cleanupStockTestData(t) + c, rec := etaContext(testUserPrefix+"eta_badid", "client", 0, true) + c.Params = gin.Params{{Key: "id", Value: "not-a-number"}} + handlers.GetOrderETA(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("ID invalide doit retourner 400: got=%d", rec.Code) + } +} + +func TestGetOrderETA_UnknownCommandReturns404(t *testing.T) { + cleanupStockTestData(t) + c, rec := etaContext(testUserPrefix+"eta_404", "client", 99999999, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusNotFound { + t.Errorf("commande inconnue doit retourner 404: got=%d", rec.Code) + } +} + +func TestGetOrderETA_ClientAccessingAnotherClientCommandReturns403(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "eta_owner") + intruder := newTestClient(t, "eta_intruder") + productID := newTestProduct(t, "EtaOwner", 10) + cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur", productID, 1, 10) + + c, rec := etaContext(intruder, "client", cmdID, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusForbidden { + t.Errorf("un autre client ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetOrderETA_LivreurNotAssignedReturns403(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "eta_lv_owner") + productID := newTestProduct(t, "EtaLvOwner", 10) + cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_real_livreur", productID, 1, 10) + + c, rec := etaContext("eta_other_livreur", "livreur", cmdID, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusForbidden { + t.Errorf("un livreur non assigné ne doit pas accéder à l'ETA: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetOrderETA_AdminBypassesOwnershipChecks(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "eta_admin_owner") + productID := newTestProduct(t, "EtaAdminOwner", 10) + cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10) + + c, rec := etaContext(testUserPrefix+"eta_admin", "admin", cmdID, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusOK { + t.Errorf("admin doit pouvoir accéder à l'ETA de n'importe quelle commande: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestGetOrderETA_DeliveredStatusReturnsEtaUnavailable(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "eta_delivered") + productID := newTestProduct(t, "EtaDelivered", 10) + cmdID := newTestCommandWithItem(t, owner, "livre", "eta_livreur_d", productID, 1, 10) + + c, rec := etaContext(owner, "client", cmdID, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusOK { + t.Fatalf("statut livre doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) { + t.Errorf("eta_available doit être false pour une commande livrée: body=%s", rec.Body.String()) + } +} + +func TestGetOrderETA_PendingStatusReturnsEtaUnavailable(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "eta_pending") + productID := newTestProduct(t, "EtaPending", 10) + cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10) + + c, rec := etaContext(owner, "client", cmdID, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusOK { + t.Fatalf("statut pending doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) { + t.Errorf("eta_available doit être false pour une commande pending: body=%s", rec.Body.String()) + } +} + +func TestGetOrderETA_ArrivedStatusReturnsEtaUnavailable(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "eta_arrived") + productID := newTestProduct(t, "EtaArrived", 10) + cmdID := newTestCommandWithItem(t, owner, "arrived", "eta_livreur_a", productID, 1, 10) + + c, rec := etaContext(owner, "client", cmdID, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusOK { + t.Fatalf("statut arrived doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) { + t.Errorf("eta_available doit être false quand le livreur est arrivé: body=%s", rec.Body.String()) + } +} + +func TestGetOrderETA_NoLivreurAssignedReturnsEtaUnavailable(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "eta_nolivreur") + productID := newTestProduct(t, "EtaNoLivreur", 10) + cmdID := newTestCommandWithItem(t, owner, "en_route", "", productID, 1, 10) + + c, rec := etaContext(owner, "client", cmdID, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusOK { + t.Fatalf("sans livreur assigné doit retourner 200: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) { + t.Errorf("eta_available doit être false sans livreur assigné: body=%s", rec.Body.String()) + } +} + +// Sans coordonnées de destination et sans cache Redis préalable, le +// handler doit tomber sur returnStaleOrUnavailable plutôt que planter. +func TestGetOrderETA_MissingDestinationCoordsFallsBackGracefully(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "eta_nodest") + productID := newTestProduct(t, "EtaNoDest", 10) + cmdID := newTestCommandWithItem(t, owner, "en_route", "eta_livreur_nodest", productID, 1, 10) + + c, rec := etaContext(owner, "client", cmdID, true) + handlers.GetOrderETA(c) + if rec.Code != http.StatusOK { + t.Fatalf("sans coordonnées destination doit quand même retourner 200: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"eta_available":false`)) { + t.Errorf("eta_available doit être false sans coordonnées destination: body=%s", rec.Body.String()) + } +} diff --git a/backend/gestion/tests/eta_geo_calc_test.go b/backend/gestion/tests/eta_geo_calc_test.go new file mode 100644 index 00000000..c9cd5f47 --- /dev/null +++ b/backend/gestion/tests/eta_geo_calc_test.go @@ -0,0 +1,79 @@ +package tests + +import ( + "math" + "testing" + + "gestion/services" +) + +// Deux points à Nantes séparés d'environ 1.1km à vol d'oiseau (Haversine). +var ( + nantesCentre = services.Coordinates{Latitude: 47.2184, Longitude: -1.5536} + nantesProche = services.Coordinates{Latitude: 47.2280, Longitude: -1.5536} +) + +func TestCalculateDistance_HaversineCorrectness(t *testing.T) { + dist := services.CalculateDistance(nantesCentre, nantesProche) + // ~0.0096 rad de latitude ≈ 1.067km — tolérance large pour la formule. + if dist < 0.9 || dist > 1.3 { + t.Errorf("distance Haversine hors plage attendue: got=%.3fkm want≈1.07km", dist) + } +} + +func TestCalculateDistance_SamePointIsZero(t *testing.T) { + dist := services.CalculateDistance(nantesCentre, nantesCentre) + if dist != 0 { + t.Errorf("distance entre un point et lui-même doit être 0: got=%.4f", dist) + } +} + +func TestCalculateETA_UnderPointOneKmReturnsMinETA(t *testing.T) { + eta := services.CalculateETA(0.05) + if eta != services.MinETA { + t.Errorf("distance < 0.1km doit retourner MinETA: got=%d want=%d", eta, services.MinETA) + } +} + +func TestCalculateETA_AppliesTwentyPercentTrafficMargin(t *testing.T) { + // 25km à 25km/h = 60min ; +20% marge = 72min (dans les bornes [MinETA, MaxETA]). + eta := services.CalculateETA(25) + want := 72 + if eta != want { + t.Errorf("ETA avec marge trafic 20%%: got=%d want=%d", eta, want) + } +} + +func TestCalculateETA_ClampsToMaxETA(t *testing.T) { + eta := services.CalculateETA(1000) + if eta != services.MaxETA { + t.Errorf("très longue distance doit être plafonnée à MaxETA: got=%d want=%d", eta, services.MaxETA) + } +} + +func TestCalculateETA_ClampsToMinETA(t *testing.T) { + // distance faible mais non nulle, donnant un temps de trajet < MinETA + // après calcul (pas la branche <0.1km, une distance différente). + eta := services.CalculateETA(0.5) + if eta < services.MinETA { + t.Errorf("ETA ne doit jamais être inférieur à MinETA: got=%d want>=%d", eta, services.MinETA) + } +} + +// Sans clé API TomTom configurée (cas de cet environnement de test), +// CalculateETAWithTomTom doit retomber sur le calcul local identique à +// CalculateDistance+CalculateETA. +func TestCalculateETAWithTomTom_FallsBackToLocalCalcWithoutAPIKey(t *testing.T) { + etaMinutes, distanceKm, err := services.CalculateETAWithTomTom(nantesCentre, nantesProche) + if err != nil { + t.Fatalf("fallback local ne doit pas retourner d'erreur: %v", err) + } + wantDistance := services.CalculateDistance(nantesCentre, nantesProche) + wantETA := services.CalculateETA(wantDistance) + if math.Abs(distanceKm-wantDistance) > 0.0001 { + t.Errorf("distance fallback: got=%.4f want=%.4f", distanceKm, wantDistance) + } + if etaMinutes != wantETA { + t.Errorf("eta fallback: got=%d want=%d", etaMinutes, wantETA) + } +} diff --git a/backend/gestion/tests/eta_last_coords_test.go b/backend/gestion/tests/eta_last_coords_test.go new file mode 100644 index 00000000..51c1058a --- /dev/null +++ b/backend/gestion/tests/eta_last_coords_test.go @@ -0,0 +1,59 @@ +package tests + +import "testing" + +func TestGetLastDeliveryCoords_NoPreviousDeliveryReturnsError(t *testing.T) { + cleanupStockTestData(t) + _, _, err := testDB.GetLastDeliveryCoords(testUserPrefix + "coords_nolivraison") + if err == nil { + t.Fatal("sans livraison précédente, une erreur est attendue") + } +} + +func TestGetLastDeliveryCoords_FallsBackToDBWhenNoCache(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "coords_db") + livreur := testUserPrefix + "coords_db_livreur" + productID := newTestProduct(t, "CoordsDB", 10) + cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10) + testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 47.2184, dest_longitude = -1.5536, updated_at = NOW() WHERE id = ?`, cmdID) + + lat, lon, err := testDB.GetLastDeliveryCoords(livreur) + if err != nil { + t.Fatalf("GetLastDeliveryCoords: %v", err) + } + if lat != 47.2184 || lon != -1.5536 { + t.Errorf("coordonnées depuis la DB: got=(%.4f,%.4f) want=(47.2184,-1.5536)", lat, lon) + } +} + +// Une fois les coordonnées lues depuis la DB, elles sont mises en cache +// Redis — un second appel doit renvoyer la valeur cachée même si la DB +// change entretemps (TTL non expiré). +func TestGetLastDeliveryCoords_CachesResultInRedis(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "coords_cache") + livreur := testUserPrefix + "coords_cache_livreur" + productID := newTestProduct(t, "CoordsCache", 10) + cmdID := newTestCommandWithItem(t, username, "livre", livreur, productID, 1, 10) + testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 48.8566, dest_longitude = 2.3522, updated_at = NOW() WHERE id = ?`, cmdID) + + lat1, _, err := testDB.GetLastDeliveryCoords(livreur) + if err != nil { + t.Fatalf("premier appel: %v", err) + } + if lat1 != 48.8566 { + t.Fatalf("premier appel doit lire la DB: got lat=%.4f want=48.8566", lat1) + } + + // Change la valeur en DB — un cache-hit doit ignorer ce changement. + testDB.GDB.Exec(`UPDATE commandes SET dest_latitude = 0, dest_longitude = 0 WHERE id = ?`, cmdID) + + lat2, lon2, err := testDB.GetLastDeliveryCoords(livreur) + if err != nil { + t.Fatalf("second appel (cache attendu): %v", err) + } + if lat2 != 48.8566 || lon2 != 2.3522 { + t.Errorf("second appel doit retourner la valeur cachée, pas la DB modifiée: got=(%.4f,%.4f) want=(48.8566,2.3522)", lat2, lon2) + } +} diff --git a/backend/gestion/tests/eta_notifications_test.go b/backend/gestion/tests/eta_notifications_test.go new file mode 100644 index 00000000..d3ea486e --- /dev/null +++ b/backend/gestion/tests/eta_notifications_test.go @@ -0,0 +1,179 @@ +package tests + +import ( + "fmt" + "gestion/db" + "strings" + "testing" + "time" +) + +const notificationsScheduledKey = "notifications:scheduled" + +// ScheduleETANotifications programme un rappel 5min et 3min avant l'arrivée +// estimée — mais seulement si l'ETA le justifie (voir bornes ci-dessous). +func TestScheduleETANotifications_SchedulesBothForLongETA(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_notif_long") + productID := newTestProduct(t, "EtaNotifLong", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10) + + before := time.Now() + if err := testDB.ScheduleETANotifications(cmdID, 10); err != nil { + t.Fatalf("ScheduleETANotifications: %v", err) + } + + score5, err := getScheduledScore(t, cmdID, "5min") + if err != nil { + t.Fatalf("notification 5min absente: %v", err) + } + score3, err := getScheduledScore(t, cmdID, "3min") + if err != nil { + t.Fatalf("notification 3min absente: %v", err) + } + + wantAt5 := before.Add(5 * time.Minute).Unix() // arrivée dans 10min - 5min = dans 5min + wantAt3 := before.Add(7 * time.Minute).Unix() // arrivée dans 10min - 3min = dans 7min + if diff := abs64(score5 - wantAt5); diff > 2 { + t.Errorf("score notification 5min: got=%d want≈%d (écart %ds)", score5, wantAt5, diff) + } + if diff := abs64(score3 - wantAt3); diff > 2 { + t.Errorf("score notification 3min: got=%d want≈%d (écart %ds)", score3, wantAt3, diff) + } + if score3 <= score5 { + t.Errorf("la notification 3min doit être programmée après la 5min (plus proche de l'arrivée): score5=%d score3=%d", score5, score3) + } +} + +// À la limite exacte (ETA = 5 min), un rappel "5 minutes avant l'arrivée" +// se déclencherait immédiatement (redondant) — il n'est donc volontairement +// pas programmé (condition stricte ">5", pas ">=5"). Seul le rappel 3min +// reste pertinent. +func TestScheduleETANotifications_ExactlyFiveMinutes_SkipsFiveMinReminder(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_notif_five") + productID := newTestProduct(t, "EtaNotifFive", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10) + + if err := testDB.ScheduleETANotifications(cmdID, 5); err != nil { + t.Fatalf("ScheduleETANotifications: %v", err) + } + + if _, err := getScheduledScore(t, cmdID, "5min"); err == nil { + t.Error("aucune notification 5min ne doit être programmée quand ETA=5min exactement") + } + if _, err := getScheduledScore(t, cmdID, "3min"); err != nil { + t.Errorf("la notification 3min doit être programmée quand ETA=5min: %v", err) + } +} + +// À ETA = 3 min, même le rappel "3 minutes avant" serait immédiat : aucune +// notification ne doit être programmée. +func TestScheduleETANotifications_ExactlyThreeMinutes_SchedulesNothing(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_notif_three") + productID := newTestProduct(t, "EtaNotifThree", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10) + + if err := testDB.ScheduleETANotifications(cmdID, 3); err != nil { + t.Fatalf("ScheduleETANotifications: %v", err) + } + + if _, err := getScheduledScore(t, cmdID, "5min"); err == nil { + t.Error("aucune notification 5min ne doit être programmée pour ETA=3min") + } + if _, err := getScheduledScore(t, cmdID, "3min"); err == nil { + t.Error("aucune notification 3min ne doit être programmée pour ETA=3min (serait immédiate)") + } +} + +// Une ETA très courte (MinETA=3min, plancher de tout le système) ne doit +// jamais programmer de notification de rappel — cohérent avec le cas ci-dessus. +func TestScheduleETANotifications_VeryShortETASchedulesNothing(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_notif_short") + productID := newTestProduct(t, "EtaNotifShort", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10) + + if err := testDB.ScheduleETANotifications(cmdID, 1); err != nil { + t.Fatalf("ScheduleETANotifications: %v", err) + } + if _, err := getScheduledScore(t, cmdID, "5min"); err == nil { + t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute") + } + if _, err := getScheduledScore(t, cmdID, "3min"); err == nil { + t.Error("aucune notification ne doit être programmée pour une ETA d'1 minute") + } +} + +// SetCommandETA (appelée par UpdateDeliveryStatus au passage en "en_route") +// déclenche automatiquement ScheduleETANotifications — vérifie l'intégration +// complète, pas seulement la fonction isolée. +func TestSetCommandETA_TriggersScheduledNotifications(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_notif_integration") + productID := newTestProduct(t, "EtaNotifIntegration", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10) + + if err := testDB.SetCommandETA(cmdID, 15); err != nil { + t.Fatalf("SetCommandETA: %v", err) + } + + if _, err := getScheduledScore(t, cmdID, "5min"); err != nil { + t.Errorf("SetCommandETA doit programmer une notification 5min: %v", err) + } + if _, err := getScheduledScore(t, cmdID, "3min"); err != nil { + t.Errorf("SetCommandETA doit programmer une notification 3min: %v", err) + } +} + +// Le message envoyé au client doit contenir une indication de temps lisible, +// pas juste le statut brut. +func TestSendETANotification_MessageContainsReadableTime(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_notif_message") + productID := newTestProduct(t, "EtaNotifMessage", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", "", productID, 1, 10) + + channel := fmt.Sprintf("notifications:command:%d", cmdID) + pubsub := db.Redis.Subscribe(db.RedisCtx, channel) + defer pubsub.Close() + // Consommer le message de confirmation d'abonnement avant de publier. + if _, err := pubsub.Receive(db.RedisCtx); err != nil { + t.Fatalf("abonnement pubsub: %v", err) + } + + testDB.SendETANotification(cmdID, "5min") + + select { + case msg := <-pubsub.Channel(): + if msg.Payload == "" { + t.Fatal("message de notification vide") + } + if !strings.Contains(msg.Payload, "5min") { + t.Errorf("le message doit indiquer le temps restant (%q): %q", "5min", msg.Payload) + } + t.Logf("message reçu: %q", msg.Payload) + case <-time.After(3 * time.Second): + t.Fatal("aucun message reçu sur le canal de notification dans le délai imparti") + } +} + +func abs64(n int64) int64 { + if n < 0 { + return -n + } + return n +} + +// getScheduledScore lit le score (timestamp Unix) d'une notification +// programmée pour "{commandID}:{suffix}" dans le sorted set Redis. +func getScheduledScore(t *testing.T, commandID int, suffix string) (int64, error) { + t.Helper() + member := fmt.Sprintf("%d:%s", commandID, suffix) + score, err := db.Redis.ZScore(db.RedisCtx, notificationsScheduledKey, member).Result() + if err != nil { + return 0, err + } + return int64(score), nil +} diff --git a/backend/gestion/tests/eta_test.go b/backend/gestion/tests/eta_test.go new file mode 100644 index 00000000..96a8f01b --- /dev/null +++ b/backend/gestion/tests/eta_test.go @@ -0,0 +1,208 @@ +package tests + +import ( + "encoding/json" + "gestion/handlers" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/gin-gonic/gin" +) + +// Les clients ont signalé ne jamais voir le temps de livraison (notifications, +// app mobile, site web). Ces tests reproduisent le chemin réel : une commande +// est assignée automatiquement (le worker de queue appelle +// SetCommandETAWithDetails, PAS SetCommandETA), puis un client consulte le +// suivi de sa commande — exactement ce que fait l'app mobile / le site web. + +func etaTestContext(username string, commandID int) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/commands/x/tracking", nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", username) + c.Set("role", "client") + c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}} + return c, rec +} + +// SetCommandETAWithDetails est le chemin utilisé par le worker +// d'auto-assignation/réoptimisation de queue (db/redis_queue_optimization.go, +// db/redis_queue_assignment.go) — de loin le plus emprunté en production. +// Il doit remplir "eta_minutes", pas seulement "total_eta_minutes", car +// c'est le champ que l'app mobile et le site web lisent (confirmé dans +// mobile/src/screens/client/OrderTrackingScreen.tsx et api.ts des deux +// frontends). +func TestSetCommandETAWithDetails_WritesEtaMinutesField(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_details_field") + productID := newTestProduct(t, "EtaDetailsField", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA", productID, 1, 10) + + if err := testDB.SetCommandETAWithDetails(cmdID, 22, 1); err != nil { + t.Fatalf("SetCommandETAWithDetails: %v", err) + } + + etaData, err := testDB.GetCommandETA(cmdID) + if err != nil { + t.Fatalf("GetCommandETA: %v", err) + } + + got, ok := etaData["eta_minutes"] + if !ok || got == "" { + t.Errorf(`champ "eta_minutes" absent après SetCommandETAWithDetails (contenu: %v) — `+ + `c'est le champ lu par le mobile et le site web, d'où l'absence de temps affiché`, etaData) + } else if got != "22" { + t.Errorf(`"eta_minutes" = %q, want "22"`, got) + } +} + +// Reproduction bout-en-bout du symptôme signalé : après une assignation +// automatique (SetCommandETAWithDetails), le client consulte le suivi de sa +// commande (GetCommandTracking, l'endpoint utilisé par l'app mobile et le +// site web) — la réponse doit exposer eta.eta_minutes. +func TestGetCommandTracking_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_tracking_client") + productID := newTestProduct(t, "EtaTrackingClient", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA2", productID, 1, 10) + + if err := testDB.SetCommandETAWithDetails(cmdID, 17, 2); err != nil { + t.Fatalf("SetCommandETAWithDetails: %v", err) + } + + c, rec := etaTestContext(username, cmdID) + handlers.GetCommandTracking(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + ETA map[string]any `json:"eta"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) + } + + etaMinutesRaw, ok := resp.ETA["eta_minutes"] + if !ok { + t.Fatalf(`la réponse de /tracking n'expose pas "eta.eta_minutes" (contenu eta: %v) — `+ + `reproduit exactement le bug signalé par les clients`, resp.ETA) + } + etaMinutesStr, _ := etaMinutesRaw.(string) + if got, _ := strconv.Atoi(etaMinutesStr); got != 17 { + t.Errorf("eta.eta_minutes: got=%v want=17", etaMinutesRaw) + } +} + +// Même reproduction via GetCommandStatus (autre endpoint de suivi, utilisé +// par l'app mobile pour le statut temps réel). +func TestGetCommandStatus_ExposesEtaMinutesAfterAutoAssignment(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_status_client") + productID := newTestProduct(t, "EtaStatusClient", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA3", productID, 1, 10) + + if err := testDB.SetCommandETAWithDetails(cmdID, 9, 1); err != nil { + t.Fatalf("SetCommandETAWithDetails: %v", err) + } + + c, rec := etaTestContext(username, cmdID) + handlers.GetCommandStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + ETA map[string]any `json:"eta"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v", err) + } + if _, ok := resp.ETA["eta_minutes"]; !ok { + t.Fatalf(`GetCommandStatus n'expose pas "eta.eta_minutes" (contenu: %v)`, resp.ETA) + } +} + +// SetCommandETA (chemin utilisé par UpdateDeliveryStatus côté livreur) doit +// lui aussi rester lisible par les lecteurs qui attendent "total_eta_minutes" +// (handlers/deleviry.go, validation_deleviry.go, geoloca.go). +func TestSetCommandETA_AlsoWritesTotalEtaMinutesField(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_simple_field") + productID := newTestProduct(t, "EtaSimpleField", 10) + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurETA4", productID, 1, 10) + + if err := testDB.SetCommandETA(cmdID, 14); err != nil { + t.Fatalf("SetCommandETA: %v", err) + } + + etaData, err := testDB.GetCommandETA(cmdID) + if err != nil { + t.Fatalf("GetCommandETA: %v", err) + } + if got, ok := etaData["total_eta_minutes"]; !ok || got != "14" { + t.Errorf(`"total_eta_minutes" = %q (présent=%v), want "14"`, got, ok) + } + if got, ok := etaData["eta_minutes"]; !ok || got != "14" { + t.Errorf(`"eta_minutes" = %q (présent=%v), want "14"`, got, ok) + } +} + +// GetDeliverymanLocationForCommand (vue admin/cabine) lisait l'ETA via +// Redis.Get sur une clé qui est en réalité un hash (HSet) — l'erreur +// WRONGTYPE était silencieusement ignorée et etaMinutes restait toujours à 0. +func TestGetDeliverymanLocationForCommand_ExposesEtaMinutes(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_admin_view_client") + productID := newTestProduct(t, "EtaAdminView", 10) + livreurUsername := testUserPrefix + "eta_admin_view_livreur" + cmdID := newTestCommandWithItem(t, username, "en_route", livreurUsername, productID, 1, 10) + + if err := testDB.SetCommandETAWithDetails(cmdID, 12, 1); err != nil { + t.Fatalf("SetCommandETAWithDetails: %v", err) + } + // Position GPS du livreur, requise par le handler avant de lire l'ETA. + if err := testDB.UpdateDeliveryPersonLocation(livreurUsername, 47.2148, -1.5584); err != nil { + t.Fatalf("UpdateDeliveryPersonLocation: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", "admin_test") + c.Set("role", "admin") + c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(cmdID)}} + + handlers.GetDeliverymanLocationForCommand(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Data struct { + ETA struct { + Minutes float64 `json:"minutes"` + HasETA bool `json:"has_eta"` + } `json:"eta"` + } `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) + } + if !resp.Data.ETA.HasETA { + t.Errorf("has_eta devrait être true, ETA pourtant définie via SetCommandETAWithDetails") + } + if resp.Data.ETA.Minutes != 12 { + t.Errorf("data.eta.minutes: got=%.0f want=12", resp.Data.ETA.Minutes) + } +} diff --git a/backend/gestion/tests/livreur_stats_test.go b/backend/gestion/tests/livreur_stats_test.go new file mode 100644 index 00000000..60d3acbe --- /dev/null +++ b/backend/gestion/tests/livreur_stats_test.go @@ -0,0 +1,137 @@ +package tests + +import ( + "encoding/json" + "gestion/handlers" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" +) + +// newTestDeliveredOrder crée une commande livrée/approuvée assignée à un +// livreur avec une date de mise à jour contrôlée (GetMyDeliveryStats groupe +// par updated_at, pas created_at). +func newTestDeliveredOrder(t *testing.T, livreurUsername, clientUsername, status string, productID int, quantite, prix, referralUsed float64, updatedAt time.Time) { + t.Helper() + var cmdID int + if err := testDB.GDB.Raw( + `INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, referral_used, created_at, updated_at) + VALUES (?, ?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`, + clientUsername, status, livreurUsername, prix, referralUsed, updatedAt, updatedAt, + ).Scan(&cmdID).Error; err != nil { + t.Fatalf("création commande livrée test: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) + VALUES (?, ?, 'item test', ?, ?, 'delivered')`, + cmdID, productID, quantite, prix, + ).Error; err != nil { + t.Fatalf("création item commande livrée test: %v", err) + } +} + +func livreurStatsContext(username string) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", username) + c.Set("role", "livreur") + return c, rec +} + +type deliveryStatsResponse struct { + Success bool `json:"success"` + TodayCount int `json:"today_count"` + TodayRevenue float64 `json:"today_revenue"` +} + +// today_count/today_revenue ne doivent compter que les livraisons du jour +// courant (livre/approved), nettes du crédit de parrainage — pas les jours +// précédents, même récents (voir by_day/by_week qui eux les agrègent). +func TestGetMyDeliveryStats_TodayCountAndRevenue(t *testing.T) { + cleanupStockTestData(t) + livreurUsername := newTestClient(t, "stats_livreur_today") + clientUsername := newTestClient(t, "stats_livreur_today_client") + productID := newTestProduct(t, "StatsLivreurToday", 100) + + now := time.Now() + yesterday := now.AddDate(0, 0, -1) + + newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 2, 40, 10, now) // net 30, aujourd'hui + newTestDeliveredOrder(t, livreurUsername, clientUsername, "approved", productID, 1, 25, 0, yesterday) // hier, ne doit pas compter dans "today" + + c, rec := livreurStatsContext(livreurUsername) + handlers.GetMyDeliveryStats(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp deliveryStatsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) + } + if !resp.Success { + t.Fatalf("success=false, body=%s", rec.Body.String()) + } + if resp.TodayCount != 1 { + t.Errorf("today_count: got=%d want=1 (la commande d'hier ne doit pas compter)", resp.TodayCount) + } + if resp.TodayRevenue != 30 { + t.Errorf("today_revenue: got=%.2f want=30 (40 - 10 de parrainage)", resp.TodayRevenue) + } +} + +// Aucune livraison aujourd'hui : today_count/today_revenue doivent être 0, +// pas une absence de champ (voir le bug initial où le seul indicateur de +// "livraisons du jour" était l'absence de ligne dans by_day). +func TestGetMyDeliveryStats_TodayCountZeroWhenNoDeliveryToday(t *testing.T) { + cleanupStockTestData(t) + livreurUsername := newTestClient(t, "stats_livreur_none") + + c, rec := livreurStatsContext(livreurUsername) + handlers.GetMyDeliveryStats(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + var resp deliveryStatsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) + } + if resp.TodayCount != 0 { + t.Errorf("today_count: got=%d want=0", resp.TodayCount) + } + if resp.TodayRevenue != 0 { + t.Errorf("today_revenue: got=%.2f want=0", resp.TodayRevenue) + } +} + +// Seules les commandes assignées à CE livreur doivent être comptées. +func TestGetMyDeliveryStats_OnlyCountsOwnDeliveries(t *testing.T) { + cleanupStockTestData(t) + livreurA := newTestClient(t, "stats_livreur_a") + livreurB := newTestClient(t, "stats_livreur_b") + client := newTestClient(t, "stats_livreur_shared_client") + productID := newTestProduct(t, "StatsLivreurIsolation", 100) + now := time.Now() + + newTestDeliveredOrder(t, livreurA, client, "approved", productID, 1, 20, 0, now) + newTestDeliveredOrder(t, livreurB, client, "approved", productID, 1, 999, 0, now) + + c, rec := livreurStatsContext(livreurA) + handlers.GetMyDeliveryStats(c) + + var resp deliveryStatsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v", err) + } + if resp.TodayCount != 1 || resp.TodayRevenue != 20 { + t.Errorf("stats livreur A ne doivent refléter que ses propres livraisons: got count=%d revenue=%.2f want count=1 revenue=20", resp.TodayCount, resp.TodayRevenue) + } +} diff --git a/backend/gestion/tests/main_test.go b/backend/gestion/tests/main_test.go new file mode 100644 index 00000000..1b47c95b --- /dev/null +++ b/backend/gestion/tests/main_test.go @@ -0,0 +1,140 @@ +// Package tests regroupe les tests de bout en bout de la gestion de stock, +// écrits contre l'API publique des paquets db/ et handlers/ (aucun accès à +// leurs symboles non exportés) — voir docker-compose.yml pour la base de +// test locale nécessaire pour les exécuter (`go test ./tests/...`). +package tests + +import ( + "fmt" + "gestion/db" + "os" + "sync/atomic" + "testing" + + "github.com/gin-gonic/gin" +) + +// testDB est l'instance partagée par tous les tests de ce paquet. Toutes les +// données créées utilisent un préfixe dédié (testUserPrefix / testProductPrefix) +// et sont nettoyées avant et après chaque test, ce qui rend la suite sans +// danger même si elle tourne contre une base partagée. +var testDB *db.Database + +const ( + testUserPrefix = "stocktest_" + testProductPrefix = "TESTSTOCK_" +) + +// testPhoneCounter garantit un numéro de téléphone unique par client de test +// (colonne UNIQUE sur clients.telephone). +var testPhoneCounter int64 + +func nextTestPhone() string { + n := atomic.AddInt64(&testPhoneCounter, 1) + return fmt.Sprintf("+3361%09d", n) +} + +func TestMain(m *testing.M) { + gin.SetMode(gin.TestMode) + testDB = db.InitDB() + db.InitRedis() + os.Exit(m.Run()) +} + +// cleanupStockTestData supprime toutes les données créées par les tests de +// gestion de stock (identifiées par leur préfixe), dans le bon ordre pour +// respecter les contraintes de clé étrangère. +func cleanupStockTestData(t *testing.T) { + t.Helper() + testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username LIKE ?)`, testUserPrefix+"%") + testDB.GDB.Exec(`DELETE FROM commandes WHERE username LIKE ?`, testUserPrefix+"%") + testDB.GDB.Exec(`DELETE FROM baskets WHERE username LIKE ?`, testUserPrefix+"%") + testDB.GDB.Exec(`DELETE FROM clients WHERE username LIKE ?`, testUserPrefix+"%") + testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name LIKE ?)`, testProductPrefix+"%") + testDB.GDB.Exec(`DELETE FROM products WHERE name LIKE ?`, testProductPrefix+"%") +} + +// newTestProduct crée un produit de test avec un stock initial donné et un +// prix actif pour quantity=1, et programme son nettoyage en fin de test. +func newTestProduct(t *testing.T, name string, stock float64) int { + t.Helper() + fullName := testProductPrefix + name + var id int + if err := testDB.GDB.Raw( + `INSERT INTO products (name, category, description, stock) VALUES (?, 'test', '', ?) RETURNING id`, + fullName, stock, + ).Scan(&id).Error; err != nil { + t.Fatalf("création produit test %q: %v", fullName, err) + } + if err := testDB.GDB.Exec( + `INSERT INTO product_prices (product_id, quantity, price, active_price) VALUES (?, 1, 10.00, true)`, + id, + ).Error; err != nil { + t.Fatalf("création prix produit test %q: %v", fullName, err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, id) + testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, id) + }) + return id +} + +// productStock relit le stock courant d'un produit directement en base. +func productStock(t *testing.T, productID int) float64 { + t.Helper() + var stock float64 + if err := testDB.GDB.Raw(`SELECT stock FROM products WHERE id = ?`, productID).Scan(&stock).Error; err != nil { + t.Fatalf("lecture stock produit %d: %v", productID, err) + } + return stock +} + +// newTestClient crée un client de test et programme son nettoyage en fin de test. +func newTestClient(t *testing.T, name string) string { + t.Helper() + username := testUserPrefix + name + if err := testDB.GDB.Exec( + `INSERT INTO clients (username, password, nom, prenom, telephone) VALUES (?, 'x', 'T', 'C', ?) + ON CONFLICT (username) DO NOTHING`, + username, nextTestPhone(), + ).Error; err != nil { + t.Fatalf("création client test %q: %v", username, err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username) + testDB.GDB.Exec(`DELETE FROM command_items WHERE command_id IN (SELECT id FROM commandes WHERE username = ?)`, username) + testDB.GDB.Exec(`DELETE FROM commandes WHERE username = ?`, username) + testDB.GDB.Exec(`DELETE FROM clients WHERE username = ?`, username) + }) + return username +} + +// newTestCommandWithItem crée directement une commande avec un item (en +// contournant le checkout), pour tester isolément les chemins d'annulation +// et de remboursement de stock. Retourne l'ID de la commande créée. +func newTestCommandWithItem(t *testing.T, username, status, livreurAssign string, productID int, quantite float64, prix float64) int { + t.Helper() + var cmdID int + if err := testDB.GDB.Raw( + `INSERT INTO commandes (username, status, livreur_assign, adresse, total_prix, created_at, updated_at) + VALUES (?, ?, NULLIF(?, ''), 'Adresse test', ?, NOW(), NOW()) RETURNING id`, + username, status, livreurAssign, prix, + ).Scan(&cmdID).Error; err != nil { + t.Fatalf("création commande test: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) + VALUES (?, ?, 'item test', ?, ?, 'pending')`, + cmdID, productID, quantite, prix, + ).Error; err != nil { + t.Fatalf("création item test: %v", err) + } + return cmdID +} + +func commandStatus(t *testing.T, commandID int) string { + t.Helper() + var status string + testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, commandID).Scan(&status) + return status +} diff --git a/backend/gestion/tests/media_s3_coverage_test.go b/backend/gestion/tests/media_s3_coverage_test.go new file mode 100644 index 00000000..a0cfc25e --- /dev/null +++ b/backend/gestion/tests/media_s3_coverage_test.go @@ -0,0 +1,276 @@ +package tests + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "gestion/handlers" + "gestion/services" + + "github.com/gin-gonic/gin" +) + +// Un MinIO local (docker) sert de backend S3 réel pour ces tests — pas de +// mock : mêmes chemins de code que RustFS en production (SDK AWS v2, +// UsePathStyle=true). +var testS3Service *services.S3Service + +func getTestS3Service(t *testing.T) *services.S3Service { + t.Helper() + if testS3Service == nil { + s3, err := services.NewS3Service( + "us-east-1", "test-products", "http://localhost:9500", + services.S3Credentials{S3KeyId: "testadmin", S3AccessKey: "testpassword123"}, + ) + if err != nil { + t.Fatalf("NewS3Service: %v", err) + } + testS3Service = s3 + } + return testS3Service +} + +// tiny1x1PNG est un PNG valide minimal (1x1 pixel transparent), pour que +// la détection MIME réelle (mimetype.DetectReader) le reconnaisse comme +// image/png. +var tiny1x1PNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +} + +func uploadMediaRequest(fileType, fileName string, fileContent []byte) (*bytes.Buffer, string) { + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + w.WriteField("type", fileType) + part, _ := w.CreateFormFile("file", fileName) + part.Write(fileContent) + w.Close() + return body, w.FormDataContentType() +} + +func mediaContext(t *testing.T, role string, body *bytes.Buffer, contentType string, productID int, mediaID int) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products/media", body) + req.Header.Set("Content-Type", contentType) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("s3Service", getTestS3Service(t)) + c.Set("username", testUserPrefix+"media_admin") + c.Set("role", role) + params := gin.Params{} + if productID != 0 { + params = append(params, gin.Param{Key: "id", Value: fmt.Sprintf("%d", productID)}) + } + if mediaID != 0 { + params = append(params, gin.Param{Key: "media_id", Value: fmt.Sprintf("%d", mediaID)}) + } + c.Params = params + return c, rec +} + +// ── UploadMedia ────────────────────────────────────────────────────────── + +func TestUploadMedia_RejectsNonAdmin(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "MediaRoleCheck", 5) + body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG) + c, rec := mediaContext(t, "cabine", body, ct, productID, 0) + handlers.UploadMedia(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role cabine doit être refusé pour UploadMedia: got=%d want=403", rec.Code) + } +} + +func TestUploadMedia_RejectsInvalidType(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "MediaBadType", 5) + body, ct := uploadMediaRequest("audio", "photo.png", tiny1x1PNG) + c, rec := mediaContext(t, "admin", body, ct, productID, 0) + handlers.UploadMedia(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("type 'audio' invalide doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String()) + } +} + +func TestUploadMedia_RejectsMimeMismatch(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "MediaMimeMismatch", 5) + // Contenu texte brut mais annoncé comme "image" — le sniff MIME réel doit le détecter. + body, ct := uploadMediaRequest("image", "fake.png", []byte("ceci n'est pas une image, juste du texte brut")) + c, rec := mediaContext(t, "admin", body, ct, productID, 0) + handlers.UploadMedia(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("contenu non-image envoyé comme type=image doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String()) + } +} + +func TestUploadMedia_UnknownProductReturns404(t *testing.T) { + cleanupStockTestData(t) + body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG) + c, rec := mediaContext(t, "admin", body, ct, 99999999, 0) + handlers.UploadMedia(c) + if rec.Code != http.StatusNotFound { + t.Errorf("produit inconnu doit retourner 404: got=%d", rec.Code) + } +} + +func TestUploadMedia_SuccessUploadsToS3AndCreatesMediaRow(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "MediaUploadOk", 5) + body, ct := uploadMediaRequest("image", "photo.png", tiny1x1PNG) + c, rec := mediaContext(t, "admin", body, ct, productID, 0) + handlers.UploadMedia(c) + if rec.Code != http.StatusCreated { + t.Fatalf("upload valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + media, err := testDB.GetMediaByProductID(productID) + if err != nil || len(media) != 1 { + t.Fatalf("un média doit être créé en base: err=%v count=%d", err, len(media)) + } + + // Vérifie que le fichier existe réellement sur MinIO (pas juste en DB). + rc, _, err := getTestS3Service(t).GetFile(t.Context(), media[0].Key) + if err != nil { + t.Fatalf("le fichier doit être récupérable depuis S3: %v", err) + } + rc.Close() + + t.Cleanup(func() { + getTestS3Service(t).DeleteFile(media[0].Key) + }) +} + +// ── DeleteMedia ────────────────────────────────────────────────────────── + +func TestDeleteMedia_RejectsNonAdminNonCabine(t *testing.T) { + cleanupStockTestData(t) + c, rec := mediaContext(t, "livreur", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, 1) + handlers.DeleteMedia(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role livreur doit être refusé: got=%d want=403", rec.Code) + } +} + +func TestDeleteMedia_UnknownMediaReturns404(t *testing.T) { + cleanupStockTestData(t) + c, rec := mediaContext(t, "admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, 99999999) + handlers.DeleteMedia(c) + if rec.Code != http.StatusNotFound { + t.Errorf("média inconnu doit retourner 404: got=%d", rec.Code) + } +} + +func TestDeleteMedia_SuccessDeletesFromS3AndDB(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "MediaDeleteOk", 5) + + uploadBody, uploadCT := uploadMediaRequest("image", "photo.png", tiny1x1PNG) + uc, urec := mediaContext(t, "admin", uploadBody, uploadCT, productID, 0) + handlers.UploadMedia(uc) + if urec.Code != http.StatusCreated { + t.Fatalf("upload préalable doit réussir: got=%d body=%s", urec.Code, urec.Body.String()) + } + + media, err := testDB.GetMediaByProductID(productID) + if err != nil || len(media) != 1 { + t.Fatalf("un média doit exister avant suppression: err=%v count=%d", err, len(media)) + } + mediaID := media[0].ID + key := media[0].Key + + c, rec := mediaContext(t, "admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", 0, mediaID) + handlers.DeleteMedia(c) + if rec.Code != http.StatusOK { + t.Fatalf("suppression doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + remaining, _ := testDB.GetMediaByProductID(productID) + if len(remaining) != 0 { + t.Errorf("le média doit être supprimé en base: got=%d lignes restantes", len(remaining)) + } + + if _, _, err := getTestS3Service(t).GetFile(t.Context(), key); err == nil { + t.Error("le fichier doit être supprimé de S3, mais il est toujours récupérable") + } +} + +// ── CreateProduct avec média réel (intégration multipart) ─────────────── +// +// Contrairement à UploadMedia (qui écrit sur S3/RustFS), le chemin média de +// CreateProduct écrit sur le DISQUE LOCAL du serveur (c.SaveUploadedFile +// vers "uploads/s/...") et ne renseigne jamais media.Key — donc ce +// fichier n'est pas récupérable via S3Service.GetFile ni via ServeMedia +// (qui lit par clé S3). C'est une incohérence d'architecture entre les deux +// chemins de code, pas juste une différence de test : à signaler séparément +// pour décision (stockage local perdu au redéploiement si le conteneur n'a +// pas de volume persistant sur "uploads/", et média invisible pour +// DeleteMedia's nettoyage S3). Ce test vérifie donc le comportement réel +// actuel (fichier sur disque local), pas un comportement souhaité en S3. +func TestCreateProduct_WithMediaSavesFileToLocalDisk(t *testing.T) { + cleanupStockTestData(t) + cat := newTestCategory(t, "CatWithMedia") + + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + w.WriteField("name", testProductPrefix+"CPMedia") + w.WriteField("category", cat) + w.WriteField("description", "d") + w.WriteField("stock", "5") + w.WriteField("prices[0][quantity]", "1") + w.WriteField("prices[0][price]", "5") + part, _ := w.CreateFormFile("media", "photo.png") + part.Write(tiny1x1PNG) + w.Close() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body) + req.Header.Set("Content-Type", w.FormDataContentType()) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("s3Service", getTestS3Service(t)) + c.Set("username", testUserPrefix+"media_admin") + c.Set("role", "admin") + + handlers.CreateProduct(c) + if rec.Code != http.StatusCreated { + t.Fatalf("création produit avec média doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var productID int + testDB.GDB.Raw(`SELECT id FROM products WHERE name = ?`, testProductPrefix+"CPMedia").Scan(&productID) + if productID == 0 { + t.Fatalf("le produit doit être créé") + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id = ?`, productID) + testDB.GDB.Exec(`DELETE FROM products WHERE id = ?`, productID) + }) + + media, err := testDB.GetMediaByProductID(productID) + if err != nil || len(media) != 1 { + t.Fatalf("un média doit être associé au produit: err=%v count=%d", err, len(media)) + } + if media[0].Key != "" { + t.Errorf("comportement actuel: media.Key doit être vide (pas de clé S3) pour le chemin CreateProduct: got=%q", media[0].Key) + } + localPath := strings.TrimPrefix(media[0].URL, "/") + if _, err := os.Stat(localPath); err != nil { + t.Errorf("le fichier doit exister sur le disque local à %q: %v", localPath, err) + } + t.Cleanup(func() { + os.Remove(localPath) + }) +} diff --git a/backend/gestion/tests/parrain_test.go b/backend/gestion/tests/parrain_test.go new file mode 100644 index 00000000..6a5dae5f --- /dev/null +++ b/backend/gestion/tests/parrain_test.go @@ -0,0 +1,160 @@ +package tests + +import ( + "sync" + "testing" +) + +// SetClientParrainAndCredit lie un parrain à un client ET crédite le parrain +// dans une seule transaction — la doc métier avertit explicitement que sans +// cette atomicité, un crédit peut être appliqué sans lien enregistré, ou +// l'inverse (lien enregistré sans jamais créditer le parrain). + +func TestSetClientParrainAndCredit_LinksAndCreditsAtomically(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_ok_client") + parrain := newTestClient(t, "parrain_ok_parrain") + setClientReferralBalance(t, parrain, 5) + + if err := testDB.SetClientParrainAndCredit(client, parrain, 10); err != nil { + t.Fatalf("SetClientParrainAndCredit: %v", err) + } + + got, err := testDB.GetClientParrain(client) + if err != nil { + t.Fatalf("GetClientParrain: %v", err) + } + if got != parrain { + t.Errorf("parrain enregistré: got=%q want=%q", got, parrain) + } + if bal := referralBalance(t, parrain); bal != 15 { + t.Errorf("solde du parrain après crédit (5 + 10): got=%.2f want=15", bal) + } +} + +// Un client qui a déjà un parrain ne doit jamais pouvoir en changer via cette +// fonction (contrainte "parrain déjà défini") — et surtout, le nouveau +// parrain proposé ne doit recevoir aucun crédit si le lien est refusé. +func TestSetClientParrainAndCredit_RejectsIfClientAlreadyHasParrain(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_already_client") + firstParrain := newTestClient(t, "parrain_already_first") + secondParrain := newTestClient(t, "parrain_already_second") + + if err := testDB.SetClientParrainAndCredit(client, firstParrain, 10); err != nil { + t.Fatalf("1er lien: %v", err) + } + + if err := testDB.SetClientParrainAndCredit(client, secondParrain, 10); err == nil { + t.Fatal("attendu un rejet : le client a déjà un parrain") + } + + if got, _ := testDB.GetClientParrain(client); got != firstParrain { + t.Errorf("le parrain enregistré ne doit pas changer: got=%q want=%q", got, firstParrain) + } + if bal := referralBalance(t, secondParrain); bal != 0 { + t.Errorf("le second parrain (lien refusé) ne doit recevoir aucun crédit: got=%.2f want=0", bal) + } + if bal := referralBalance(t, firstParrain); bal != 10 { + t.Errorf("le premier parrain garde son crédit initial, pas de second crédit: got=%.2f want=10", bal) + } +} + +// Scénario exact mis en garde par la doc métier : si le parrain indiqué +// n'existe pas, le crédit échoue — et le lien parrain (première moitié de la +// transaction) doit être annulé avec, pas laissé enregistré tout seul. +func TestSetClientParrainAndCredit_RejectsUnknownParrain_RollsBackLink(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_unknown_client") + unknownParrain := testUserPrefix + "does_not_exist_parrain" + + if err := testDB.SetClientParrainAndCredit(client, unknownParrain, 10); err == nil { + t.Fatal("attendu une erreur : le parrain n'existe pas") + } + + got, err := testDB.GetClientParrain(client) + if err != nil { + t.Fatalf("GetClientParrain: %v", err) + } + if got != "" { + t.Errorf("le lien parrain ne doit PAS être enregistré si le crédit échoue (rollback complet): got=%q want=\"\"", got) + } +} + +// Quand le parrainage est désactivé (ReferralEnabled=false côté handler), le +// montant crédité vaut 0 — la liaison doit tout de même réussir sans tenter +// de créditer personne. +func TestSetClientParrainAndCredit_ZeroCreditLinksWithoutCrediting(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_zero_client") + parrain := newTestClient(t, "parrain_zero_parrain") + + if err := testDB.SetClientParrainAndCredit(client, parrain, 0); err != nil { + t.Fatalf("SetClientParrainAndCredit avec montant nul: %v", err) + } + + if got, _ := testDB.GetClientParrain(client); got != parrain { + t.Errorf("le lien doit être enregistré même sans crédit: got=%q want=%q", got, parrain) + } + if bal := referralBalance(t, parrain); bal != 0 { + t.Errorf("aucun crédit ne doit être appliqué avec un montant nul: got=%.2f want=0", bal) + } +} + +func TestSetClientParrainAndCredit_RejectsUnknownClient(t *testing.T) { + cleanupStockTestData(t) + parrain := newTestClient(t, "parrain_unknown_client_target") + unknownClient := testUserPrefix + "does_not_exist_client" + + if err := testDB.SetClientParrainAndCredit(unknownClient, parrain, 10); err == nil { + t.Fatal("attendu une erreur : le client cible n'existe pas") + } + if bal := referralBalance(t, parrain); bal != 0 { + t.Errorf("le parrain ne doit pas être crédité si le client cible est introuvable: got=%.2f want=0", bal) + } +} + +// Deux tentatives concurrentes de parrainage sur le MÊME client (deux +// parrains différents) ne doivent en laisser passer qu'une seule — la +// condition "parrain IS NULL OR parrain = ''" de l'UPDATE sérialise +// naturellement les deux tentatives au niveau de la ligne. +func TestSetClientParrainAndCredit_ConcurrentSetOnSameClientOnlyOneSucceeds(t *testing.T) { + cleanupStockTestData(t) + client := newTestClient(t, "parrain_concurrent_client") + parrainA := newTestClient(t, "parrain_concurrent_a") + parrainB := newTestClient(t, "parrain_concurrent_b") + + var wg sync.WaitGroup + errs := make([]error, 2) + wg.Add(2) + go func() { + defer wg.Done() + errs[0] = testDB.SetClientParrainAndCredit(client, parrainA, 10) + }() + go func() { + defer wg.Done() + errs[1] = testDB.SetClientParrainAndCredit(client, parrainB, 10) + }() + wg.Wait() + + successCount := 0 + for _, err := range errs { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("une seule tentative concurrente de parrainage doit réussir: got=%d succès", successCount) + } + + finalParrain, _ := testDB.GetClientParrain(client) + if finalParrain != parrainA && finalParrain != parrainB { + t.Fatalf("parrain final inattendu: %q", finalParrain) + } + + balA := referralBalance(t, parrainA) + balB := referralBalance(t, parrainB) + if (balA == 10) == (balB == 10) { + t.Errorf("exactement un des deux parrains doit être crédité de 10, pas les deux ni aucun: balA=%.2f balB=%.2f", balA, balB) + } +} diff --git a/backend/gestion/tests/penalty_test.go b/backend/gestion/tests/penalty_test.go new file mode 100644 index 00000000..ba41deec --- /dev/null +++ b/backend/gestion/tests/penalty_test.go @@ -0,0 +1,405 @@ +package tests + +import ( + "bytes" + "gestion/handlers" + "net/http" + "net/http/httptest" + "strconv" + "sync" + "testing" + + "github.com/gin-gonic/gin" +) + +func clientAmendeAndCount(t *testing.T, username string) (amende float64, count int) { + t.Helper() + var row struct { + Amende float64 `gorm:"column:amende"` + CancellationsCount int `gorm:"column:cancellations_count"` + } + if err := testDB.GDB.Raw( + `SELECT COALESCE(amende, 0) AS amende, COALESCE(cancellations_count, 0) AS cancellations_count FROM clients WHERE username = ?`, + username, + ).Scan(&row).Error; err != nil { + t.Fatalf("lecture amende/cancellations_count: %v", err) + } + return row.Amende, row.CancellationsCount +} + +// ── CalculateCancellationPenalty : barème par défaut ──────────────────────── +// (0->20, 1->50, 2->100, 3 et plus->150 — voir DefaultSettings) + +func TestCalculateCancellationPenalty_MatchesDefaultTiers(t *testing.T) { + cases := []struct { + cancellationsCount int + wantPenalty int + }{ + {0, 20}, + {1, 50}, + {2, 100}, + {3, 150}, + {10, 150}, // palier "4e et plus", plafonné + } + + for _, c := range cases { + cleanupStockTestData(t) + username := newTestClient(t, "penalty_tier") + testDB.GDB.Exec(`UPDATE clients SET cancellations_count = ? WHERE username = ?`, c.cancellationsCount, username) + + got, err := testDB.CalculateCancellationPenalty(username) + if err != nil { + t.Fatalf("CalculateCancellationPenalty (count=%d): %v", c.cancellationsCount, err) + } + if got != c.wantPenalty { + t.Errorf("count=%d: penalty=%d want=%d", c.cancellationsCount, got, c.wantPenalty) + } + } +} + +// ── ApplyCancellationPenalty ("client absent" livreur) : cumul + concurrence ─ + +func TestApplyCancellationPenalty_AccumulatesAcrossSequentialCalls(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "penalty_sequential") + + wantPenalties := []int{20, 50, 100, 150} + wantCumulative := []float64{20, 70, 170, 320} + + for i, want := range wantPenalties { + penalty, err := testDB.ApplyCancellationPenalty(username) + if err != nil { + t.Fatalf("appel %d: %v", i+1, err) + } + if penalty != want { + t.Errorf("appel %d: penalty=%d want=%d", i+1, penalty, want) + } + amende, count := clientAmendeAndCount(t, username) + if amende != wantCumulative[i] { + t.Errorf("appel %d: amende cumulée=%.2f want=%.2f", i+1, amende, wantCumulative[i]) + } + if count != i+1 { + t.Errorf("appel %d: cancellations_count=%d want=%d", i+1, count, i+1) + } + } +} + +// Plusieurs "client absent" quasi simultanés sur le même client (deux +// livreurs différents annulant chacun une commande de ce client au même +// moment) ne doivent pas se marcher dessus (verrou FOR UPDATE). +func TestApplyCancellationPenalty_ConcurrentCallsDoNotLoseUpdates(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "penalty_concurrent") + + n := 4 + var wg sync.WaitGroup + errs := make([]error, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, errs[idx] = testDB.ApplyCancellationPenalty(username) + }(i) + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Errorf("appel concurrent %d: erreur inattendue: %v", i, err) + } + } + + amende, count := clientAmendeAndCount(t, username) + if count != n { + t.Errorf("cancellations_count après %d appels concurrents: got=%d want=%d", n, count, n) + } + if amende != 320 { // 20+50+100+150, un seul palier consommé par appel + t.Errorf("amende après %d appels concurrents: got=%.2f want=320.00 (pas de mise à jour perdue)", n, amende) + } +} + +// ── CheckCommandETAExistsAndValid (bug corrigé : lisait la clé avec Get au lieu de HGetAll) ─ + +func TestCheckCommandETAExistsAndValid_FalseWhenNoETA(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_check_none") + productID := newTestProduct(t, "EtaCheckNone", 5) + cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurCheckETA", productID, 1, 10) + + if testDB.CheckCommandETAExistsAndValid(cmdID) { + t.Error("aucune ETA définie: attendu false") + } +} + +func TestCheckCommandETAExistsAndValid_TrueWhenETASet(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "eta_check_valid") + productID := newTestProduct(t, "EtaCheckValid", 5) + cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurCheckETA2", productID, 1, 10) + + if err := testDB.SetCommandETA(cmdID, 15); err != nil { + t.Fatalf("SetCommandETA: %v", err) + } + + if !testDB.CheckCommandETAExistsAndValid(cmdID) { + t.Error("ETA valide définie: attendu true") + } +} + +// ── CancelCommandAtomic : détection d'annulation tardive et pénalité ──────── + +func TestCancelCommandAtomic_NoPenaltyWithoutLivreur(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_penalty_no_livreur") + productID := newTestProduct(t, "CancelPenaltyNoLivreur", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", false) + if err != nil { + t.Fatalf("CancelCommandAtomic: %v", err) + } + if penalty != 0 { + t.Errorf("aucune pénalité attendue sans livreur assigné: got=%d", penalty) + } + amende, count := clientAmendeAndCount(t, username) + if amende != 0 { + t.Errorf("amende doit rester à 0: got=%.2f", amende) + } + if count != 1 { + t.Errorf("cancellations_count doit tout de même être incrémenté: got=%d want=1", count) + } +} + +func TestCancelCommandAtomic_LateCancel_EnRoute_RequiresForceConfirmation(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_penalty_enroute_noforce") + productID := newTestProduct(t, "CancelPenaltyEnrouteNoforce", 5) + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurLate1", productID, 1, 10) + + _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false) + if err == nil || err.Error() != "confirmation requise" { + t.Fatalf(`attendu l'erreur "confirmation requise", got=%v`, err) + } + + if got := commandStatus(t, cmdID); got != "en_route" { + t.Errorf("le statut ne doit pas changer sans confirmation: got=%s want=en_route", got) + } + if got := productStock(t, productID); got != 5 { + t.Errorf("le stock ne doit pas être remboursé sans confirmation: got=%.2f want=5", got) + } + amende, _ := clientAmendeAndCount(t, username) + if amende != 0 { + t.Errorf("aucune amende ne doit être appliquée sans confirmation: got=%.2f", amende) + } +} + +func TestCancelCommandAtomic_LateCancel_Arrived_RequiresForceConfirmation(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_penalty_arrived_noforce") + productID := newTestProduct(t, "CancelPenaltyArrivedNoforce", 5) + cmdID := newTestCommandWithItem(t, username, "arrived", testUserPrefix+"livreurLate2", productID, 1, 10) + + _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false) + if err == nil || err.Error() != "confirmation requise" { + t.Fatalf(`attendu l'erreur "confirmation requise" pour status=arrived, got=%v`, err) + } +} + +func TestCancelCommandAtomic_LateCancelWithForce_AppliesPenalty(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_penalty_enroute_force") + productID := newTestProduct(t, "CancelPenaltyEnrouteForce", 5) + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurLate3", productID, 2, 20) + + penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true) + if err != nil { + t.Fatalf("CancelCommandAtomic avec force: %v", err) + } + if penalty != 20 { // 1ère annulation de ce client + t.Errorf("penalty: got=%d want=20", penalty) + } + + if got := commandStatus(t, cmdID); got != "cancelled" { + t.Errorf("statut: got=%s want=cancelled", got) + } + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après annulation confirmée (5 initial + 2 remboursés): got=%.2f want=7", got) + } + amende, count := clientAmendeAndCount(t, username) + if amende != 20 { + t.Errorf("amende: got=%.2f want=20", amende) + } + if count != 1 { + t.Errorf("cancellations_count: got=%d want=1", count) + } +} + +// Un livreur assigné mais sans statut avancé (assigned) et sans ETA connue +// n'est pas considéré comme une annulation tardive : pas besoin de force. +func TestCancelCommandAtomic_AssignedWithoutETA_NoForceNeeded(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_penalty_assigned_noeta") + productID := newTestProduct(t, "CancelPenaltyAssignedNoeta", 5) + cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurLate4", productID, 1, 10) + + penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", false) + if err != nil { + t.Fatalf("CancelCommandAtomic: %v", err) + } + if penalty != 0 { + t.Errorf("aucune pénalité attendue (pas d'ETA, statut non avancé): got=%d", penalty) + } +} + +// Un livreur assigné avec une ETA valide en cache doit être traité comme une +// annulation tardive, même si le statut est encore "assigned" (régression du +// bug CheckCommandETAExistsAndValid corrigé ci-dessus). +func TestCancelCommandAtomic_AssignedWithValidETA_RequiresForce(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_penalty_assigned_eta") + productID := newTestProduct(t, "CancelPenaltyAssignedEta", 5) + cmdID := newTestCommandWithItem(t, username, "assigned", testUserPrefix+"livreurLate5", productID, 1, 10) + + if err := testDB.SetCommandETA(cmdID, 12); err != nil { + t.Fatalf("SetCommandETA: %v", err) + } + + _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false) + if err == nil || err.Error() != "confirmation requise" { + t.Fatalf(`attendu "confirmation requise" (ETA valide définie): got=%v`, err) + } + + // Avec force=true, la pénalité doit maintenant s'appliquer. + penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true) + if err != nil { + t.Fatalf("CancelCommandAtomic avec force: %v", err) + } + if penalty != 20 { + t.Errorf("penalty: got=%d want=20", penalty) + } +} + +func TestCancelCommandAtomic_PenaltyProgressesAcrossMultipleLateCancellations(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_penalty_progression") + productID := newTestProduct(t, "CancelPenaltyProgression", 20) + + wantPenalties := []int{20, 50, 100, 150} + for i, want := range wantPenalties { + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurProg", productID, 1, 10) + penalty, err := testDB.CancelCommandAtomic(cmdID, username, "test", true) + if err != nil { + t.Fatalf("annulation %d: %v", i+1, err) + } + if penalty != want { + t.Errorf("annulation %d: penalty=%d want=%d", i+1, penalty, want) + } + } + + amende, count := clientAmendeAndCount(t, username) + if count != 4 { + t.Errorf("cancellations_count: got=%d want=4", count) + } + if amende != 320 { // 20+50+100+150 + t.Errorf("amende cumulée: got=%.2f want=320.00", amende) + } +} + +func TestCancelCommandAtomic_NonCancellableStatusRejected(t *testing.T) { + for _, status := range []string{"livre", "approved", "cancelled", "disabled"} { + t.Run(status, func(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_penalty_terminal_"+status) + productID := newTestProduct(t, "CancelPenaltyTerminal"+status, 5) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", true); err == nil { + t.Errorf("statut %q devrait être rejeté même avec force=true", status) + } + }) + } +} + +func TestCancelCommandAtomic_WrongOwnerRejected(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "cancel_penalty_owner") + intruder := newTestClient(t, "cancel_penalty_intruder") + productID := newTestProduct(t, "CancelPenaltyOwner", 5) + cmdID := newTestCommandWithItem(t, owner, "pending", "", productID, 1, 10) + + if _, err := testDB.CancelCommandAtomic(cmdID, intruder, "test", false); err == nil { + t.Error("un client ne doit pas pouvoir annuler la commande d'un autre client") + } +} + +// ── Flux "client absent" (livreur annule depuis 'arrived') via le handler ─── + +func deliveryStatusContext(livreurUsername string, commandID int, status, notes string) (*gin.Context, *httptest.ResponseRecorder) { + body := []byte(`{"status":"` + status + `","notes":"` + notes + `"}`) + req := httptest.NewRequest(http.MethodPut, "/x", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", livreurUsername) + c.Set("role", "livreur") + c.Params = gin.Params{{Key: "id", Value: strconv.Itoa(commandID)}} + return c, rec +} + +// Quand le livreur marque le client absent (annulation depuis "arrived"), +// l'amende doit être appliquée au CLIENT, jamais au livreur — règle métier +// explicite (comprehension-metier). +func TestUpdateDeliveryStatus_ClientAbsent_AppliesPenaltyToClientNotLivreur(t *testing.T) { + cleanupStockTestData(t) + clientUsername := newTestClient(t, "penalty_absent_client") + livreurUsername := testUserPrefix + "penalty_absent_livreur" + productID := newTestProduct(t, "PenaltyAbsent", 5) + cmdID := newTestCommandWithItem(t, clientUsername, "arrived", livreurUsername, productID, 1, 10) + + c, rec := deliveryStatusContext(livreurUsername, cmdID, "cancelled", "Client absent") + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + clientAmende, clientCount := clientAmendeAndCount(t, clientUsername) + if clientAmende != 20 { + t.Errorf("amende client après 'client absent': got=%.2f want=20", clientAmende) + } + if clientCount != 1 { + t.Errorf("cancellations_count client: got=%d want=1", clientCount) + } + + if got := commandStatus(t, cmdID); got != "cancelled" { + t.Errorf("statut commande: got=%s want=cancelled", got) + } + if got := productStock(t, productID); got != 6 { + t.Errorf("stock après remboursement (5 initial + 1 remboursé): got=%.2f want=6", got) + } +} + +// Annuler depuis un statut autre que "arrived"/"livre" (ex: "en_route") ne +// doit PAS déclencher la pénalité "client absent" — ce n'est pas le même +// motif d'annulation. +func TestUpdateDeliveryStatus_CancelFromEnRoute_DoesNotApplyClientAbsentPenalty(t *testing.T) { + cleanupStockTestData(t) + clientUsername := newTestClient(t, "penalty_enroute_client") + livreurUsername := testUserPrefix + "penalty_enroute_livreur" + productID := newTestProduct(t, "PenaltyEnrouteCancel", 5) + cmdID := newTestCommandWithItem(t, clientUsername, "en_route", livreurUsername, productID, 1, 10) + + c, rec := deliveryStatusContext(livreurUsername, cmdID, "cancelled", "Problème livraison") + handlers.UpdateDeliveryStatus(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + clientAmende, _ := clientAmendeAndCount(t, clientUsername) + if clientAmende != 0 { + t.Errorf("aucune amende ne doit être appliquée depuis en_route: got=%.2f", clientAmende) + } +} diff --git a/backend/gestion/tests/points_approval_staff_test.go b/backend/gestion/tests/points_approval_staff_test.go new file mode 100644 index 00000000..5fc107d7 --- /dev/null +++ b/backend/gestion/tests/points_approval_staff_test.go @@ -0,0 +1,256 @@ +package tests + +import ( + "sync" + "testing" + + "gestion/models" +) + +// ── ApproveDeliveryAtomicByStaff : confirmation de réception par admin/cabine +// à la place du client. Contrairement au chemin client (ApproveDeliveryAtomic), +// aucune vérification de propriétaire n'est faite ici (le staff agit au nom +// du client) — mais la contrainte de statut ('livre' uniquement) est +// identique, et la double-approbation renvoie une VRAIE erreur (pas un no-op +// silencieux comme côté client). + +func TestApproveDeliveryAtomicByStaff_CreditsPointsExactlyOnApproval(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "staff_approve_ok") + productID := newTestProduct(t, "StaffApproveOk", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + pts, _, clientOut, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test") + if err != nil { + t.Fatalf("ApproveDeliveryAtomicByStaff: %v", err) + } + if pts != 6 { + t.Errorf("points retournés: got=%d want=6", pts) + } + if clientOut != username { + t.Errorf("client retourné: got=%q want=%q", clientOut, username) + } + if got := commandStatus(t, cmdID); got != "approved" { + t.Errorf("statut après confirmation staff: got=%s want=approved", got) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points_extra après confirmation staff: got=%d want=6", got) + } +} + +func TestApproveDeliveryAtomicByStaff_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) { + cleanupStockTestData(t) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + + for _, status := range []string{"pending", "assigned", "en_route", "arrived"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "staff_reject_"+status) + productID := newTestProduct(t, "StaffReject"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err == nil { + t.Fatalf("attendu un rejet pour une commande en statut %q", status) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 0 { + t.Errorf("aucun point ne doit être crédité (statut=%s): got=%d want=0", status, got) + } + if got := commandStatus(t, cmdID); got != status { + t.Errorf("le statut ne doit pas changer: got=%s want=%s", got, status) + } + }) + } +} + +// Contrairement à ApproveDeliveryAtomic (client), une seconde confirmation +// staff sur une commande déjà approuvée renvoie une VRAIE erreur, pas un +// no-op silencieux — divergence de comportement à documenter explicitement. +func TestApproveDeliveryAtomicByStaff_DoubleApprove_ReturnsErrorAndDoesNotDoublePoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "staff_double") + productID := newTestProduct(t, "StaffDouble", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err != nil { + t.Fatalf("1ère confirmation: %v", err) + } + if _, _, _, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test"); err == nil { + t.Fatal("la 2e confirmation sur une commande déjà approuvée doit renvoyer une erreur (contrairement au chemin client)") + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après double confirmation staff: got=%d want=6 (un seul crédit)", got) + } +} + +func TestApproveDeliveryAtomicByStaff_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "staff_concurrent") + productID := newTestProduct(t, "StaffConcurrent", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, _, _, errs[idx] = testDB.ApproveDeliveryAtomicByStaff(cmdID, "admin_test") + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range errs { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("une seule confirmation concurrente doit réussir: got=%d succès", successCount) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après confirmations concurrentes: got=%d want=6 (un seul crédit)", got) + } +} + +// Le staff n'est pas le client : aucune vérification de propriétaire n'est +// faite (comportement voulu, à la différence du chemin client). +func TestApproveDeliveryAtomicByStaff_NoOwnershipCheck_AnyStaffCanConfirmAnyClient(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "staff_no_owner_check") + productID := newTestProduct(t, "StaffNoOwnerCheck", 20) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + if _, _, clientOut, err := testDB.ApproveDeliveryAtomicByStaff(cmdID, "un_autre_membre_staff"); err != nil { + t.Fatalf("un membre du staff quelconque doit pouvoir confirmer la réception: %v", err) + } else if clientOut != username { + t.Errorf("client retourné: got=%q want=%q", clientOut, username) + } +} + +// ── ValidateDeliveryAtomic : validation admin en masse ────────────────────── +// +// Divergence de règle métier volontaire (confirmée) : contrairement aux deux +// autres chemins d'approbation (client et staff), qui exigent tous deux le +// statut 'livre', ValidateDeliveryAtomic accepte "pending", "assigned", +// "en_route" ET "livre" — c'est un override admin assumé pour régulariser une +// commande gérée hors flux normal, pas un bug. Les tests suivants documentent +// ce comportement réel pour qu'une future régression involontaire soit détectée. + +func TestValidateDeliveryAtomic_AcceptsAllDocumentedStatusesAndCreditsPoints(t *testing.T) { + cleanupStockTestData(t) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + + for _, status := range []string{"pending", "assigned", "en_route", "livre"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "validate_status_"+status) + productID := newTestProduct(t, "ValidateStatus"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + pts, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test") + if err != nil { + t.Fatalf("ValidateDeliveryAtomic depuis le statut %q: %v", status, err) + } + if pts != 6 { + t.Errorf("points depuis statut %q: got=%d want=6", status, pts) + } + if got := commandStatus(t, cmdID); got != "approved" { + t.Errorf("statut final: got=%s want=approved", got) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points_extra depuis statut %q: got=%d want=6", status, got) + } + }) + } +} + +func TestValidateDeliveryAtomic_RejectsStatusOutsideAllowedList(t *testing.T) { + cleanupStockTestData(t) + for _, status := range []string{"cancelled", "pending_payment", "arrived"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "validate_invalid_"+status) + productID := newTestProduct(t, "ValidateInvalid"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err == nil { + t.Fatalf("statut %q ne fait pas partie de la liste autorisée, attendu un rejet", status) + } + if got := commandStatus(t, cmdID); got != status { + t.Errorf("le statut ne doit pas changer: got=%s want=%s", got, status) + } + }) + } +} + +// Ici aussi la double-validation renvoie une vraie erreur ("commande déjà +// approuvée"), pas un no-op silencieux. +func TestValidateDeliveryAtomic_DoubleValidate_ReturnsErrorAndDoesNotDoublePoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "validate_double") + productID := newTestProduct(t, "ValidateDouble", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err != nil { + t.Fatalf("1ère validation: %v", err) + } + if _, err := testDB.ValidateDeliveryAtomic(cmdID, "admin_test"); err == nil { + t.Fatal("la 2e validation sur une commande déjà approuvée doit renvoyer une erreur") + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après double validation: got=%d want=6 (un seul crédit)", got) + } +} + +func TestValidateDeliveryAtomic_ConcurrentValidate_CreditsPointsOnlyOnce(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "validate_concurrent") + productID := newTestProduct(t, "ValidateConcurrent", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + ptsResults := make([]int, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ptsResults[idx], errs[idx] = testDB.ValidateDeliveryAtomic(cmdID, "admin_test") + }(i) + } + wg.Wait() + + successCount := 0 + for i := range n { + if errs[i] == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("une seule validation concurrente doit réussir: got=%d succès", successCount) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après validations concurrentes: got=%d want=6 (un seul crédit)", got) + } +} diff --git a/backend/gestion/tests/points_approval_test.go b/backend/gestion/tests/points_approval_test.go new file mode 100644 index 00000000..27d6dc14 --- /dev/null +++ b/backend/gestion/tests/points_approval_test.go @@ -0,0 +1,404 @@ +package tests + +import ( + "encoding/json" + "gestion/models" + "sync" + "testing" + + "gorm.io/gorm" +) + +// setPointsPoolsSettings remplace la configuration des pools de points pour la +// durée du test (table app_settings, clé "points_pools"), et restaure l'état +// par défaut en fin de test. +func setPointsPoolsSettings(t *testing.T, pools []models.PointsPool) { + t.Helper() + data, err := json.Marshal(pools) + if err != nil { + t.Fatalf("marshal pools: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO app_settings (key, value) VALUES ('points_pools', ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + string(data), + ).Error; err != nil { + t.Fatalf("setPointsPoolsSettings: %v", err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_pools'`) + }) +} + +// setPointsRewardSettings configure le seuil de récompense globale (déduction +// de points lors de la consommation d'un article récompense). +func setPointsRewardSettings(t *testing.T, reward models.PointsReward) { + t.Helper() + data, err := json.Marshal(reward) + if err != nil { + t.Fatalf("marshal points_reward: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO app_settings (key, value) VALUES ('points_reward', ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + string(data), + ).Error; err != nil { + t.Fatalf("setPointsRewardSettings: %v", err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = 'points_reward'`) + }) +} + +// insertRewardCommandItem ajoute directement un item récompense à une +// commande déjà créée (contourne le checkout, pour isoler le calcul de points). +func insertRewardCommandItem(t *testing.T, commandID, productID int, quantite, prix float64, poolKey string) { + t.Helper() + if err := testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status, is_reward, reward_pool_key) + VALUES (?, ?, 'item reward test', ?, ?, 'pending', true, ?)`, + commandID, productID, quantite, prix, poolKey, + ).Error; err != nil { + t.Fatalf("insertRewardCommandItem: %v", err) + } +} + +func clientPointsExtra(t *testing.T, username string) map[string]int { + t.Helper() + extra, _, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + return extra +} + +// ── CalculateAndAddPointsForCommandTx : logique bas niveau ────────────────── + +func TestCalculateAndAddPointsForCommandTx_CreditsPointsPerPoolFromTiers(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_tiers_ok") + productID := newTestProduct(t, "PointsTiersOk", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{ + {Min: 30, Max: 50, Points: 1}, + {Min: 60, Max: 0, Points: 5}, + }}, + }) + // Total commande = 60€ -> palier "60 et plus" = 5 points. + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) + + err := testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 5 { + t.Errorf("points calculés: got=%d want=5", pts) + } + if cat != "Pool Test" { + t.Errorf("catégorie: got=%q want=%q", cat, "Pool Test") + } + return nil + }) + if err != nil { + t.Fatalf("transaction: %v", err) + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 5 { + t.Errorf("points_extra[pool_0] après crédit: got=%d want=5", got) + } +} + +func TestCalculateAndAddPointsForCommandTx_NoPoolsConfigured_ReturnsZero(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_no_pools") + productID := newTestProduct(t, "PointsNoPools", 20) + setPointsPoolsSettings(t, []models.PointsPool{}) // aucun pool configuré + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) + + testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, cat, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 0 || cat != "" { + t.Errorf("sans pool configuré: got pts=%d cat=%q, want 0/\"\"", pts, cat) + } + return nil + }) + + if got := clientPointsExtra(t, username); len(got) != 0 { + t.Errorf("points_extra ne doit pas bouger sans pool configuré: got=%v", got) + } +} + +// Un pool existe mais aucune de ses catégories ne correspond à la catégorie +// des produits commandés ("test", posée par newTestProduct) -> 0 point. +func TestCalculateAndAddPointsForCommandTx_CategoryNotInAnyPool_ReturnsZero(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_cat_mismatch") + productID := newTestProduct(t, "PointsCatMismatch", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Autre Catégorie", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{ + {Min: 30, Max: 0, Points: 5}, + }}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 6, 60) + + testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 0 { + t.Errorf("catégorie hors pool: got pts=%d want=0", pts) + } + return nil + }) + + if got := clientPointsExtra(t, username); len(got) != 0 { + t.Errorf("points_extra ne doit pas bouger si aucune catégorie ne matche: got=%v", got) + } +} + +// Deux pools indépendants : seul celui dont la catégorie correspond aux +// produits de la commande doit recevoir des points. +func TestCalculateAndAddPointsForCommandTx_MultiplePoolsIndependent(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_multi_pool") + productID := newTestProduct(t, "PointsMultiPool", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Match", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 7}}}, + {Key: "pool_1", Name: "Pool No Match", Categories: []string{"autre_categorie"}, Tiers: []models.PointsTier{{Min: 30, Max: 0, Points: 99}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 2, 20) + + testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 7 { + t.Errorf("total points (seul pool_0 doit contribuer): got=%d want=7", pts) + } + return nil + }) + + extra := clientPointsExtra(t, username) + if extra["pool_0"] != 7 { + t.Errorf("pool_0: got=%d want=7", extra["pool_0"]) + } + if extra["pool_1"] != 0 { + t.Errorf("pool_1 ne doit recevoir aucun point (catégorie non matchée): got=%d want=0", extra["pool_1"]) + } +} + +// Un article récompense présent dans la commande déduit "threshold" points du +// pool correspondant, en plus des points gagnés par les articles payants de +// la même commande. +func TestCalculateAndAddPointsForCommandTx_RewardItemDeductsThresholdFromPoolPoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_reward_deduct") + paidProductID := newTestProduct(t, "PointsRewardDeductPaid", 20) + rewardProductID := newTestProduct(t, "PointsRewardDeductFree", 5) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 3}}}, + }) + setPointsRewardSettings(t, models.PointsReward{Threshold: 20, Type: "free_product"}) + setClientPoolPoints(t, username, "pool_0", 25) // solde de départ avant cette commande + + cmdID := newTestCommandWithItem(t, username, "livre", "", paidProductID, 1, 10) + insertRewardCommandItem(t, cmdID, rewardProductID, 1, 0, "pool_0") + + testDB.GDB.Transaction(func(tx *gorm.DB) error { + pts, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("CalculateAndAddPointsForCommandTx: %v", err) + } + if pts != 3 { + t.Errorf("points gagnés sur l'article payant: got=%d want=3", pts) + } + return nil + }) + + // 25 (initial) + 3 (gagnés) - 20 (seuil déduit pour la récompense consommée) = 8. + if got := clientPointsExtra(t, username)["pool_0"]; got != 8 { + t.Errorf("points_extra[pool_0] après crédit + déduction récompense: got=%d want=8", got) + } +} + +// Propriété documentée du design : cette fonction bas niveau n'a aucune garde +// d'idempotence intégrée — appeler deux fois pour la même commande double les +// points. C'est le rôle de l'appelant (ApproveDeliveryAtomic, via son +// verrou de transition de statut livre->approved) d'empêcher un second appel. +func TestCalculateAndAddPointsForCommandTx_CalledTwice_DoublesPoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "points_called_twice") + productID := newTestProduct(t, "PointsCalledTwice", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 4}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + for i := 0; i < 2; i++ { + testDB.GDB.Transaction(func(tx *gorm.DB) error { + _, _, err := testDB.CalculateAndAddPointsForCommandTx(tx, cmdID, username) + if err != nil { + t.Fatalf("appel %d: %v", i+1, err) + } + return nil + }) + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 8 { + t.Errorf("deux appels bruts doublent les points (4+4): got=%d want=8 — ceci documente pourquoi ApproveDeliveryAtomic doit rester le seul appelant", got) + } +} + +// ── ApproveDeliveryAtomic : la vraie règle métier "points uniquement à +// l'approbation, jamais avant" ──────────────────────────────────────────── + +func TestApproveDeliveryAtomic_CreditsPointsExactlyOnApproval(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "approve_credits_points") + productID := newTestProduct(t, "ApproveCreditsPoints", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + pts, _, err := testDB.ApproveDeliveryAtomic(cmdID, username) + if err != nil { + t.Fatalf("ApproveDeliveryAtomic: %v", err) + } + if pts != 6 { + t.Errorf("points retournés par l'approbation: got=%d want=6", pts) + } + if got := commandStatus(t, cmdID); got != "approved" { + t.Errorf("statut après approbation: got=%s want=approved", got) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points_extra après approbation: got=%d want=6", got) + } +} + +// La règle centrale : tant que la commande n'est pas "livre", l'approbation +// doit être rejetée et AUCUN point ne doit être crédité. +func TestApproveDeliveryAtomic_RejectsNonLivreStatus_NoPointsCredited(t *testing.T) { + cleanupStockTestData(t) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + + for _, status := range []string{"pending", "assigned", "en_route", "arrived"} { + t.Run(status, func(t *testing.T) { + username := newTestClient(t, "approve_reject_"+status) + productID := newTestProduct(t, "ApproveReject"+status, 20) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 1, 10) + + if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err == nil { + t.Fatalf("attendu un rejet pour une commande en statut %q", status) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 0 { + t.Errorf("aucun point ne doit être crédité pour une commande non 'livre' (statut=%s): got=%d want=0", status, got) + } + if got := commandStatus(t, cmdID); got != status { + t.Errorf("le statut ne doit pas changer sur une approbation rejetée: got=%s want=%s", got, status) + } + }) + } +} + +// Double approbation (retry réseau / double-tap client) : la seconde doit +// être un no-op silencieux, jamais un second crédit de points. +func TestApproveDeliveryAtomic_DoubleApprove_DoesNotDoublePoints(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "approve_double") + productID := newTestProduct(t, "ApproveDouble", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, username); err != nil { + t.Fatalf("1ère approbation: %v", err) + } + pts2, _, err := testDB.ApproveDeliveryAtomic(cmdID, username) + if err != nil { + t.Fatalf("2e approbation (doit être idempotente, pas une erreur): %v", err) + } + if pts2 != 0 { + t.Errorf("2e approbation ne doit rapporter aucun point: got=%d want=0", pts2) + } + + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après double approbation (doivent rester crédités une seule fois): got=%d want=6", got) + } +} + +func TestApproveDeliveryAtomic_ConcurrentApprove_CreditsPointsOnlyOnce(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "approve_concurrent") + productID := newTestProduct(t, "ApproveConcurrent", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 1, 10) + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + ptsResults := make([]int, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + ptsResults[idx], _, errs[idx] = testDB.ApproveDeliveryAtomic(cmdID, username) + }(i) + } + wg.Wait() + + // ApproveDeliveryAtomic traite une commande déjà approuvée comme un no-op + // idempotent (err=nil, pts=0), pas comme une erreur — donc le critère de + // "vrai succès" est pts>0 (crédit réellement appliqué), pas err==nil. + freshCreditCount := 0 + for i := range n { + if errs[i] != nil { + t.Errorf("appel %d: erreur inattendue: %v", i, errs[i]) + continue + } + if ptsResults[i] > 0 { + freshCreditCount++ + } + } + if freshCreditCount != 1 { + t.Errorf("une seule approbation concurrente doit réellement créditer des points: got=%d", freshCreditCount) + } + if got := clientPointsExtra(t, username)["pool_0"]; got != 6 { + t.Errorf("points après approbations concurrentes: got=%d want=6 (un seul crédit)", got) + } +} + +func TestApproveDeliveryAtomic_WrongOwnerRejected(t *testing.T) { + cleanupStockTestData(t) + owner := newTestClient(t, "approve_owner") + intruder := newTestClient(t, "approve_intruder") + productID := newTestProduct(t, "ApproveWrongOwner", 20) + setPointsPoolsSettings(t, []models.PointsPool{ + {Key: "pool_0", Name: "Pool Test", Categories: []string{"test"}, Tiers: []models.PointsTier{{Min: 0, Max: 0, Points: 6}}}, + }) + cmdID := newTestCommandWithItem(t, owner, "livre", "", productID, 1, 10) + + if _, _, err := testDB.ApproveDeliveryAtomic(cmdID, intruder); err == nil { + t.Fatal("un client ne doit pas pouvoir approuver la commande d'un autre client") + } + if got := clientPointsExtra(t, intruder)["pool_0"]; got != 0 { + t.Errorf("l'intrus ne doit recevoir aucun point: got=%d want=0", got) + } + if got := clientPointsExtra(t, owner)["pool_0"]; got != 0 { + t.Errorf("le propriétaire ne doit pas non plus recevoir de point tant que ce n'est pas lui qui approuve: got=%d want=0", got) + } + if got := commandStatus(t, cmdID); got != "livre" { + t.Errorf("statut ne doit pas changer sur une tentative d'un intrus: got=%s want=livre", got) + } +} diff --git a/backend/gestion/tests/referral_test.go b/backend/gestion/tests/referral_test.go new file mode 100644 index 00000000..a40cd7af --- /dev/null +++ b/backend/gestion/tests/referral_test.go @@ -0,0 +1,254 @@ +package tests + +import ( + "sync" + "testing" +) + +// setClientReferralBalance fixe directement le solde de crédit parrainage +// d'un client de test (contourne le flux normal de parrainage/checkout pour +// tester isolément le débit/crédit). +func setClientReferralBalance(t *testing.T, username string, amount float64) { + t.Helper() + if err := testDB.GDB.Exec( + `UPDATE clients SET referral_balance = ? WHERE username = ?`, amount, username, + ).Error; err != nil { + t.Fatalf("setClientReferralBalance: %v", err) + } +} + +func referralBalance(t *testing.T, username string) float64 { + t.Helper() + balance, err := testDB.GetClientReferralBalance(username) + if err != nil { + t.Fatalf("GetClientReferralBalance: %v", err) + } + return balance +} + +// ── DebitReferralBalance ───────────────────────────────────────────────────── + +func TestDebitReferralBalance_SucceedsWithSufficientBalance(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_debit_ok") + setClientReferralBalance(t, username, 50) + + if err := testDB.DebitReferralBalance(username, 30); err != nil { + t.Fatalf("DebitReferralBalance: %v", err) + } + + if got := referralBalance(t, username); got != 20 { + t.Errorf("solde après débit (50 - 30): got=%.2f want=20", got) + } +} + +func TestDebitReferralBalance_FailsWithInsufficientBalance(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_debit_insuff") + setClientReferralBalance(t, username, 10) + + if err := testDB.DebitReferralBalance(username, 30); err == nil { + t.Fatal("attendu une erreur (solde 10€ insuffisant pour débiter 30€)") + } + + if got := referralBalance(t, username); got != 10 { + t.Errorf("solde ne doit pas bouger si le débit échoue: got=%.2f want=10", got) + } +} + +// Un montant nul ou négatif est un no-op silencieux (cas "pas de crédit +// parrainage utilisé" au checkout) — ne doit jamais faire échouer ni modifier +// le solde. +func TestDebitReferralBalance_ZeroOrNegativeAmountIsNoop(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_debit_zero") + setClientReferralBalance(t, username, 15) + + if err := testDB.DebitReferralBalance(username, 0); err != nil { + t.Errorf("montant nul ne doit jamais échouer: %v", err) + } + if err := testDB.DebitReferralBalance(username, -5); err != nil { + t.Errorf("montant négatif ne doit jamais échouer: %v", err) + } + if got := referralBalance(t, username); got != 15 { + t.Errorf("solde ne doit pas bouger sur un débit nul/négatif: got=%.2f want=15", got) + } +} + +// Trois débits concurrents pour un solde qui ne permet qu'un seul d'entre eux +// ne doivent en laisser passer qu'un seul (verrou FOR UPDATE) — même classe de +// bug que le double-submit checkout, appliquée au solde de parrainage. +func TestDebitReferralBalance_ConcurrentDebitsDoNotOverspend(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_debit_concurrent") + setClientReferralBalance(t, username, 30) + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + errs[idx] = testDB.DebitReferralBalance(username, 30) + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range errs { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("un seul débit concurrent de 30€ sur un solde de 30€ doit réussir: got=%d succès", successCount) + } + if got := referralBalance(t, username); got != 0 { + t.Errorf("solde final après débits concurrents: got=%.2f want=0 (un seul débit appliqué)", got) + } +} + +// ── CreditClientReferral ───────────────────────────────────────────────────── + +func TestCreditClientReferral_AddsAmount(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_credit_ok") + setClientReferralBalance(t, username, 10) + + if err := testDB.CreditClientReferral(username, 25); err != nil { + t.Fatalf("CreditClientReferral: %v", err) + } + if got := referralBalance(t, username); got != 35 { + t.Errorf("solde après crédit (10 + 25): got=%.2f want=35", got) + } +} + +func TestCreditClientReferral_RejectsNonPositiveAmount(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_credit_negative") + setClientReferralBalance(t, username, 10) + + if err := testDB.CreditClientReferral(username, 0); err == nil { + t.Error("un crédit de montant nul doit être rejeté") + } + if err := testDB.CreditClientReferral(username, -5); err == nil { + t.Error("un crédit de montant négatif doit être rejeté") + } + if got := referralBalance(t, username); got != 10 { + t.Errorf("solde ne doit pas bouger sur un crédit rejeté: got=%.2f want=10", got) + } +} + +func TestCreditClientReferral_FailsForUnknownClient(t *testing.T) { + if err := testDB.CreditClientReferral(testUserPrefix+"does_not_exist", 10); err == nil { + t.Fatal("attendu une erreur pour un client inexistant") + } +} + +// ── ResetClientReferralBalance ─────────────────────────────────────────────── + +func TestResetClientReferralBalance_SetsToZero(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_reset") + setClientReferralBalance(t, username, 42) + + if err := testDB.ResetClientReferralBalance(username); err != nil { + t.Fatalf("ResetClientReferralBalance: %v", err) + } + if got := referralBalance(t, username); got != 0 { + t.Errorf("solde après reset: got=%.2f want=0", got) + } +} + +// ── Séquence complète débit -> checkout, reproduisant exactement le flux du +// handler ValidateBasket (handlers/panier.go) : débit AVANT la création de la +// commande, puis re-crédit compensatoire si CreateCommandWithAddress échoue. +// Sans ce re-crédit, un client perdrait sèchement son crédit de parrainage +// sur un checkout qui a pourtant échoué (violation explicite de la doc métier). + +func TestReferral_DebitThenCheckoutFailure_RecreditsBalance(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_checkout_fail") + productID := newTestProduct(t, "ReferralCheckoutFail", 1) + setClientReferralBalance(t, username, 20) + + insertNormalBasketRow(t, username, productID, 5, 50) // 5 demandés pour 1 en stock -> échec garanti + + referralUsed := 20.0 + if err := testDB.DebitReferralBalance(username, referralUsed); err != nil { + t.Fatalf("DebitReferralBalance: %v", err) + } + if got := referralBalance(t, username); got != 0 { + t.Fatalf("précondition: solde débité: got=%.2f want=0", got) + } + + _, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err == nil { + t.Fatal("attendu un échec de checkout (stock insuffisant)") + } + // Reproduction exacte de la compensation faite par le handler en cas d'échec. + if err := testDB.CreditClientReferral(username, referralUsed); err != nil { + t.Fatalf("CreditClientReferral (compensation): %v", err) + } + + if got := referralBalance(t, username); got != 20 { + t.Errorf("le crédit parrainage doit être intégralement restauré après échec du checkout: got=%.2f want=20", got) + } + if got := productStock(t, productID); got != 1 { + t.Errorf("stock ne doit pas bouger si le checkout échoue: got=%.2f want=1", got) + } +} + +func TestReferral_DebitThenCheckoutSuccess_BalanceStaysDebited(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_checkout_ok") + productID := newTestProduct(t, "ReferralCheckoutOk", 10) + setClientReferralBalance(t, username, 20) + + insertNormalBasketRow(t, username, productID, 3, 30) + + if err := testDB.DebitReferralBalance(username, 20); err != nil { + t.Fatalf("DebitReferralBalance: %v", err) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + if got := referralBalance(t, username); got != 0 { + t.Errorf("le crédit parrainage reste débité après un checkout réussi: got=%.2f want=0", got) + } + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après checkout réussi: got=%.2f want=7", got) + } +} + +// Combine les deux règles demandées explicitement : réclamation de +// récompense (même produit en reward + en achat normal) ET utilisation du +// crédit de parrainage sur la même commande. +func TestReferral_CombinedWithSameProductRewardAndNormal_AllInvariantsHold(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "referral_combined_reward") + productID := newTestProduct(t, "ReferralCombinedReward", 20) + setClientReferralBalance(t, username, 15) + + insertRewardBasketRow(t, username, productID, 1) + insertNormalBasketRow(t, username, productID, 4, 40) + + if err := testDB.DebitReferralBalance(username, 15); err != nil { + t.Fatalf("DebitReferralBalance: %v", err) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + // 20 initial - 1 (reward) - 4 (normal) = 15. + if got := productStock(t, productID); got != 15 { + t.Errorf("stock après checkout combiné (reward+normal même produit + parrainage): got=%.2f want=15", got) + } + if got := referralBalance(t, username); got != 0 { + t.Errorf("crédit parrainage débité et non restauré après succès: got=%.2f want=0", got) + } +} diff --git a/backend/gestion/tests/rewards_handler_test.go b/backend/gestion/tests/rewards_handler_test.go new file mode 100644 index 00000000..e5494a44 --- /dev/null +++ b/backend/gestion/tests/rewards_handler_test.go @@ -0,0 +1,132 @@ +package tests + +import ( + "bytes" + "encoding/json" + "gestion/db" + "gestion/handlers" + "gestion/models" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func claimRewardContext(username string, body []byte) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/points/claim", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", username) + return c, rec +} + +func configureRewardSettings(t *testing.T, reward *models.PointsReward) { + t.Helper() + settings := db.DefaultSettings() + settings.PointsReward = reward + if err := testDB.UpdateSettings(settings); err != nil { + t.Fatalf("UpdateSettings: %v", err) + } +} + +// Flux complet réel : POST /points/claim avec un seuil atteint doit ajouter +// le produit récompense configuré au panier et décompter la récompense. +func TestClaimMyReward_HTTPFlow_AddsRewardToBasketAndDecrementsAvailable(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_flow") + rewardProductID := newTestProduct(t, "RewardHTTPFlow", 5) + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Type: "free_product", + Description: "Un produit offert", + RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, + }) + setClientPoolPoints(t, username, "pool_0", 20) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code != http.StatusOK { + t.Fatalf("status HTTP: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + RemainingRewards int `json:"remaining_rewards"` + ProductAdded bool `json:"product_added"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("décodage réponse: %v body=%s", err, rec.Body.String()) + } + if !resp.Success || !resp.ProductAdded { + t.Fatalf("réclamation devrait réussir avec produit ajouté: %+v", resp) + } + if resp.RemainingRewards != 0 { + t.Errorf("remaining_rewards: got=%d want=0", resp.RemainingRewards) + } + + rows := basketRewardItems(t, username) + if len(rows) != 1 || rows[0].ProductID != rewardProductID { + t.Errorf("le produit récompense doit être dans le panier: %+v", rows) + } +} + +func TestClaimMyReward_HTTPFlow_RejectsWhenBelowThreshold(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_below") + rewardProductID := newTestProduct(t, "RewardHTTPBelow", 5) + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Type: "free_product", + RewardItems: []models.RewardItem{{ProductID: rewardProductID, Quantity: 1, Price: 12}}, + }) + setClientPoolPoints(t, username, "pool_0", 5) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code != http.StatusConflict { + t.Fatalf("status HTTP: got=%d want=%d body=%s", rec.Code, http.StatusConflict, rec.Body.String()) + } +} + +// Si un item récompense configuré par l'admin pointe vers un produit +// supprimé/inexistant, la réclamation entière doit échouer — la récompense +// ne doit pas être consommée sans qu'aucun produit ne soit livré au client +// (ClaimPoolReward + AddRewardsToBasket sont maintenant dans la même +// transaction via ClaimPoolRewardAndAddToBasket). +func TestClaimMyReward_HTTPFlow_FailsAtomicallyWhenProductMissing(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_http_missing_product") + + configureRewardSettings(t, &models.PointsReward{ + Threshold: 20, + Type: "free_product", + RewardItems: []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 12}}, // produit inexistant + }) + setClientPoolPoints(t, username, "pool_0", 20) + + body, _ := json.Marshal(map[string]string{"pool_key": "pool_0"}) + c, rec := claimRewardContext(username, body) + handlers.ClaimMyReward(c) + + if rec.Code == http.StatusOK { + t.Fatalf("la réclamation ne doit pas réussir avec un produit récompense invalide, body=%s", rec.Body.String()) + } + + _, redeemed, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + if redeemed["pool_0"] != 0 { + t.Errorf("la récompense ne doit PAS être consommée si le produit est introuvable: got redeemed=%d want=0", redeemed["pool_0"]) + } +} diff --git a/backend/gestion/tests/rewards_test.go b/backend/gestion/tests/rewards_test.go new file mode 100644 index 00000000..b60b535f --- /dev/null +++ b/backend/gestion/tests/rewards_test.go @@ -0,0 +1,411 @@ +package tests + +import ( + "gestion/models" + "strings" + "sync" + "testing" +) + +// setClientPoolPoints fixe directement les points cumulés d'un client pour un +// pool donné (contourne le flux normal d'accumulation pour tester isolément +// la réclamation de récompense). +func setClientPoolPoints(t *testing.T, username, poolKey string, points int) { + t.Helper() + if err := testDB.GDB.Exec( + `UPDATE clients SET points_extra = jsonb_set(COALESCE(points_extra, '{}'::jsonb), ARRAY[?], to_jsonb(?::int)) WHERE username = ?`, + poolKey, points, username, + ).Error; err != nil { + t.Fatalf("setClientPoolPoints: %v", err) + } +} + +type rewardBasketRow struct { + ProductID int `gorm:"column:product_id"` + Quantity float64 `gorm:"column:quantity"` + Price float64 `gorm:"column:price"` + IsReward bool `gorm:"column:is_reward"` + RewardPoolKey string `gorm:"column:reward_pool_key"` +} + +func basketRewardItems(t *testing.T, username string) []rewardBasketRow { + t.Helper() + var rows []rewardBasketRow + if err := testDB.GDB.Raw( + `SELECT product_id, quantity, price, is_reward, reward_pool_key + FROM baskets WHERE username = ? AND is_reward = true`, username, + ).Scan(&rows).Error; err != nil { + t.Fatalf("lecture panier récompense: %v", err) + } + return rows +} + +// ── ClaimPoolReward : seuil, atomicité, épuisement ────────────────────────── + +func TestClaimPoolReward_BelowThresholdFails(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_below_threshold") + setClientPoolPoints(t, username, "pool_0", 19) + + if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil { + t.Fatal("attendu une erreur : 19 points < seuil 20") + } else if !strings.Contains(err.Error(), "pas de récompense disponible") { + t.Errorf("message d'erreur inattendu: %v", err) + } +} + +func TestClaimPoolReward_ExactlyAtThresholdSucceeds(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_exact_threshold") + setClientPoolPoints(t, username, "pool_0", 20) + + remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20) + if err != nil { + t.Fatalf("ClaimPoolReward: %v", err) + } + if remaining != 0 { + t.Errorf("remaining: got=%d want=0 (1 récompense gagnée, 1 réclamée)", remaining) + } +} + +func TestClaimPoolReward_MultipleRewardsEarned(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_multiple") + setClientPoolPoints(t, username, "pool_0", 45) // 45/20 = 2 récompenses gagnées + + remaining1, err := testDB.ClaimPoolReward(username, "pool_0", 20) + if err != nil { + t.Fatalf("1er claim: %v", err) + } + if remaining1 != 1 { + t.Errorf("après 1er claim: got=%d want=1", remaining1) + } + + remaining2, err := testDB.ClaimPoolReward(username, "pool_0", 20) + if err != nil { + t.Fatalf("2e claim: %v", err) + } + if remaining2 != 0 { + t.Errorf("après 2e claim: got=%d want=0", remaining2) + } + + if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err == nil { + t.Fatal("3e claim: attendu une erreur (récompenses épuisées)") + } +} + +// Trois réclamations concurrentes pour un client n'ayant droit qu'à UNE seule +// récompense ne doivent en laisser passer qu'une seule (verrou FOR UPDATE). +func TestClaimPoolReward_ConcurrentClaimsDoNotOverclaim(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_concurrent") + setClientPoolPoints(t, username, "pool_0", 20) // 1 seule récompense disponible + + var wg sync.WaitGroup + n := 3 + errs := make([]error, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, errs[idx] = testDB.ClaimPoolReward(username, "pool_0", 20) + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range errs { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("un seul claim concurrent doit réussir: got=%d succès", successCount) + } + + _, redeemed, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + if redeemed["pool_0"] != 1 { + t.Errorf("compteur redeemed après claims concurrents: got=%d want=1", redeemed["pool_0"]) + } +} + +// Les pools sont indépendants : les points d'un pool ne doivent pas permettre +// de réclamer une récompense sur un autre pool. +func TestClaimPoolReward_PoolsAreIndependent(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_pool_isolation") + setClientPoolPoints(t, username, "pool_0", 20) + // pool_1 n'a aucun point. + + if _, err := testDB.ClaimPoolReward(username, "pool_1", 20); err == nil { + t.Fatal("attendu une erreur : aucun point sur pool_1") + } + if remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil { + t.Errorf("pool_0 devrait rester réclamable: %v", err) + } else if remaining != 0 { + t.Errorf("remaining pool_0: got=%d want=0", remaining) + } +} + +// ── ClaimPoolRewardAndAddToBasket : atomicité réclamation + livraison ─────── + +func TestClaimPoolRewardAndAddToBasket_SucceedsAndDecrementsAvailable(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_combined_ok") + productID := newTestProduct(t, "RewardCombinedOk", 5) + setClientPoolPoints(t, username, "pool_0", 20) + + remaining, added, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20, + []models.RewardItem{{ProductID: productID, Quantity: 1, Price: 10}}) + if err != nil { + t.Fatalf("ClaimPoolRewardAndAddToBasket: %v", err) + } + if remaining != 0 { + t.Errorf("remaining: got=%d want=0", remaining) + } + if len(added) != 1 { + t.Fatalf("articles ajoutés: got=%d want=1", len(added)) + } + + _, redeemed, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + if redeemed["pool_0"] != 1 { + t.Errorf("redeemed: got=%d want=1", redeemed["pool_0"]) + } +} + +// Si le produit récompense est introuvable, ni la récompense ni le panier ne +// doivent être modifiés (rollback complet de la transaction combinée). +func TestClaimPoolRewardAndAddToBasket_RollsBackBothOnInvalidProduct(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_combined_rollback") + setClientPoolPoints(t, username, "pool_0", 20) + + _, _, err := testDB.ClaimPoolRewardAndAddToBasket(username, "pool_0", 20, + []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}}) + if err == nil { + t.Fatal("attendu une erreur pour un produit récompense inexistant") + } + + _, redeemed, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + if redeemed["pool_0"] != 0 { + t.Errorf("la récompense ne doit pas être consommée si l'ajout au panier échoue: got redeemed=%d want=0", redeemed["pool_0"]) + } + if rows := basketRewardItems(t, username); len(rows) != 0 { + t.Errorf("aucun article récompense ne doit rester en panier: got=%d", len(rows)) + } +} + +// ── AddRewardsToBasket : flags et remplacement ────────────────────────────── + +func TestAddRewardsToBasket_SetsRewardFlagsAndZeroPrice(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_basket_flags") + productID := newTestProduct(t, "RewardBasketFlags", 20) + + items := []models.RewardItem{{ProductID: productID, Quantity: 2, Price: 15.0}} + added, err := testDB.AddRewardsToBasket(username, items, "pool_0") + if err != nil { + t.Fatalf("AddRewardsToBasket: %v", err) + } + if len(added) != 1 { + t.Fatalf("nombre d'articles ajoutés: got=%d want=1", len(added)) + } + + rows := basketRewardItems(t, username) + if len(rows) != 1 { + t.Fatalf("articles récompense en base: got=%d want=1", len(rows)) + } + row := rows[0] + if !row.IsReward { + t.Error("is_reward doit être true") + } + if row.RewardPoolKey != "pool_0" { + t.Errorf("reward_pool_key: got=%q want=%q", row.RewardPoolKey, "pool_0") + } + if row.Price != 0 { + t.Errorf("prix affiché doit être 0 (gratuit): got=%.2f", row.Price) + } + if row.Quantity != 2 { + t.Errorf("quantité: got=%.2f want=2", row.Quantity) + } +} + +// Réclamer une nouvelle récompense doit remplacer les articles récompense +// précédents, pas les cumuler (évite d'accumuler indéfiniment des articles +// gratuits si le client reclique plusieurs fois). +func TestAddRewardsToBasket_ReplacesPreviousRewardItems(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_basket_replace") + productA := newTestProduct(t, "RewardReplaceA", 20) + productB := newTestProduct(t, "RewardReplaceB", 20) + + if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productA, Quantity: 1, Price: 10}}, "pool_0"); err != nil { + t.Fatalf("1er AddRewardsToBasket: %v", err) + } + if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: productB, Quantity: 1, Price: 10}}, "pool_0"); err != nil { + t.Fatalf("2e AddRewardsToBasket: %v", err) + } + + rows := basketRewardItems(t, username) + if len(rows) != 1 { + t.Fatalf("un seul article récompense doit rester après remplacement: got=%d", len(rows)) + } + if rows[0].ProductID != productB { + t.Errorf("l'article récompense restant doit être le dernier réclamé: got=%d want=%d", rows[0].ProductID, productB) + } +} + +// Un article récompense pointant vers un produit inexistant doit faire +// échouer l'ajout, sans rien insérer du tout (transaction). +func TestAddRewardsToBasket_FailsOnUnknownProduct(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_basket_unknown") + + if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: 999999999, Quantity: 1, Price: 10}}, "pool_0"); err == nil { + t.Fatal("attendu une erreur pour un produit inexistant") + } + + rows := basketRewardItems(t, username) + if len(rows) != 0 { + t.Errorf("aucun article ne doit être ajouté si le produit est introuvable: got=%d", len(rows)) + } +} + +// ── Chaîne complète : réclamation -> panier -> checkout -> stock ──────────── + +// C'est le scénario demandé explicitement : vérifier que le stock est bien +// déduit pour un article obtenu par récompense, exactement comme un article +// payant (règle métier explicite : les récompenses ne sont jamais exclues du +// décompte de stock). +func TestRewardClaim_FullChain_DecrementsStockAtCheckout(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_full_chain") + rewardProductID := newTestProduct(t, "RewardFullChainFree", 5) + paidProductID := newTestProduct(t, "RewardFullChainPaid", 10) + + setClientPoolPoints(t, username, "pool_0", 20) + + remaining, err := testDB.ClaimPoolReward(username, "pool_0", 20) + if err != nil { + t.Fatalf("ClaimPoolReward: %v", err) + } + if remaining != 0 { + t.Errorf("remaining: got=%d want=0", remaining) + } + + if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil { + t.Fatalf("AddRewardsToBasket: %v", err) + } + if _, err := testDB.AddToBasket(username, paidProductID, 3); err != nil { + t.Fatalf("AddToBasket (article payant): %v", err) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + if got := productStock(t, rewardProductID); got != 3 { + t.Errorf("stock article récompense après checkout (5 initial - 2 offerts): got=%.2f want=3", got) + } + if got := productStock(t, paidProductID); got != 7 { + t.Errorf("stock article payant après checkout (10 initial - 3 achetés): got=%.2f want=7", got) + } + + _, redeemed, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + if redeemed["pool_0"] != 1 { + t.Errorf("compteur de récompenses réclamées après checkout: got=%d want=1", redeemed["pool_0"]) + } +} + +// Si le checkout échoue (stock insuffisant sur l'article payant du même +// panier), l'article récompense ne doit pas non plus voir son stock décrémenté +// (rollback complet, cohérent avec le comportement déjà vérifié pour les +// articles payants). +func TestRewardClaim_CheckoutFailure_DoesNotDecrementRewardStock(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_checkout_fail") + rewardProductID := newTestProduct(t, "RewardCheckoutFailFree", 5) + shortProductID := newTestProduct(t, "RewardCheckoutFailShort", 1) + + setClientPoolPoints(t, username, "pool_0", 20) + if _, err := testDB.ClaimPoolReward(username, "pool_0", 20); err != nil { + t.Fatalf("ClaimPoolReward: %v", err) + } + if _, err := testDB.AddRewardsToBasket(username, []models.RewardItem{{ProductID: rewardProductID, Quantity: 2, Price: 15}}, "pool_0"); err != nil { + t.Fatalf("AddRewardsToBasket: %v", err) + } + // Article payant en rupture pour forcer l'échec du checkout. + if err := testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`, + username, shortProductID, + ).Error; err != nil { + t.Fatalf("insertion panier insuffisant: %v", err) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil { + t.Fatal("attendu un échec de checkout (stock insuffisant sur l'article payant)") + } + + if got := productStock(t, rewardProductID); got != 5 { + t.Errorf("stock article récompense ne doit pas bouger si le checkout échoue: got=%.2f want=5", got) + } +} + +// ── ResetClientRedeemed (admin) ────────────────────────────────────────────── + +func TestResetClientRedeemed_SpecificPool(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_reset_specific") + setClientPoolPoints(t, username, "pool_0", 20) + setClientPoolPoints(t, username, "pool_1", 20) + testDB.ClaimPoolReward(username, "pool_0", 20) + testDB.ClaimPoolReward(username, "pool_1", 20) + + if err := testDB.ResetClientRedeemed(username, "pool_0"); err != nil { + t.Fatalf("ResetClientRedeemed: %v", err) + } + + _, redeemed, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + if redeemed["pool_0"] != 0 { + t.Errorf("pool_0 doit être remis à zéro: got=%d", redeemed["pool_0"]) + } + if redeemed["pool_1"] != 1 { + t.Errorf("pool_1 ne doit pas être affecté: got=%d want=1", redeemed["pool_1"]) + } +} + +func TestResetClientRedeemed_AllPools(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_reset_all") + setClientPoolPoints(t, username, "pool_0", 20) + setClientPoolPoints(t, username, "pool_1", 20) + testDB.ClaimPoolReward(username, "pool_0", 20) + testDB.ClaimPoolReward(username, "pool_1", 20) + + if err := testDB.ResetClientRedeemed(username, ""); err != nil { + t.Fatalf("ResetClientRedeemed: %v", err) + } + + _, redeemed, err := testDB.GetClientPointsAndRewards(username) + if err != nil { + t.Fatalf("GetClientPointsAndRewards: %v", err) + } + if len(redeemed) != 0 { + t.Errorf("tous les pools doivent être remis à zéro: got=%v", redeemed) + } +} diff --git a/backend/gestion/tests/stats_advanced_test.go b/backend/gestion/tests/stats_advanced_test.go new file mode 100644 index 00000000..225b64b2 --- /dev/null +++ b/backend/gestion/tests/stats_advanced_test.go @@ -0,0 +1,162 @@ +package tests + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gestion/handlers" + "gestion/models" + + "github.com/gin-gonic/gin" +) + +func adminStatsContext() (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/stats", nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + return c, rec +} + +// insertStatsCommand insère une commande directement avec un created_at +// choisi, pour contrôler précisément le jour de semaine/heure agrégés. +func insertStatsCommand(t *testing.T, username, status, createdAt string) { + t.Helper() + testDB.GDB.Exec( + `INSERT INTO commandes (username, status, adresse, total_prix, created_at, updated_at) + VALUES (?, ?, 'Adresse stats test', 10, ?::timestamp, ?::timestamp)`, + username, status, createdAt, createdAt, + ) +} + +func TestGetAdminStats_ZeroOrdersAvgPerDayIsZeroNoPanic(t *testing.T) { + cleanupStockTestData(t) + // Table commandes peut contenir des données d'autres tests, mais aucune + // n'utilise ce username isolé — on vérifie juste l'absence de panic/NaN + // et que la structure de réponse est bien formée avec les vraies + // données actuelles de la base de test (qui peut être non-vide). + c, rec := adminStatsContext() + handlers.GetAdminStats(c) + if rec.Code != http.StatusOK { + t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + Summary struct { + AvgPerDay float64 `json:"avg_per_day"` + } `json:"summary"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("réponse JSON invalide: %v", err) + } + if resp.Summary.AvgPerDay < 0 { + t.Errorf("avg_per_day ne doit jamais être négatif: got=%f", resp.Summary.AvgPerDay) + } +} + +func TestGetAdminStats_PeakWeekdayMatchesBusiestDay(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_peak") + // 2024-01-08 est un lundi, 2024-01-07 un dimanche (DOW Postgres: 0=dimanche). + insertStatsCommand(t, username, "pending", "2024-01-08 10:00:00") + insertStatsCommand(t, username, "pending", "2024-01-08 11:00:00") + insertStatsCommand(t, username, "pending", "2024-01-08 12:00:00") + insertStatsCommand(t, username, "pending", "2024-01-07 10:00:00") + + c, rec := adminStatsContext() + handlers.GetAdminStats(c) + if rec.Code != http.StatusOK { + t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + Summary struct { + PeakWeekday string `json:"peak_weekday"` + } `json:"summary"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("réponse JSON invalide: %v", err) + } + if resp.Summary.PeakWeekday != "Lundi" { + t.Errorf("peak_weekday doit être le jour avec le plus de commandes: got=%q want=Lundi", resp.Summary.PeakWeekday) + } +} + +func TestGetAdminStats_ByQuantitySortedDescendingByTotalOrders(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_byqty") + prodLow := newTestProduct(t, "ByQtyLow", 100) + prodHigh := newTestProduct(t, "ByQtyHigh", 100) + + // prodHigh: 3 commandes ; prodLow: 1 commande. + for range 3 { + newTestCommandWithItem(t, username, "pending", "", prodHigh, 1, 10) + } + newTestCommandWithItem(t, username, "pending", "", prodLow, 1, 10) + + c, rec := adminStatsContext() + handlers.GetAdminStats(c) + if rec.Code != http.StatusOK { + t.Fatalf("GetAdminStats doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + var resp struct { + ByQuantity []struct { + ProductID int `json:"product_id"` + TotalOrders int `json:"total_orders"` + } `json:"by_quantity"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("réponse JSON invalide: %v", err) + } + // Vérifie l'ordre décroissant global (pas seulement nos deux produits, + // d'autres tests peuvent avoir laissé des données) et que prodHigh + // apparaît avant prodLow. + highIdx, lowIdx := -1, -1 + for i, r := range resp.ByQuantity { + if r.ProductID == prodHigh { + highIdx = i + } + if r.ProductID == prodLow { + lowIdx = i + } + if i > 0 && r.TotalOrders > resp.ByQuantity[i-1].TotalOrders { + t.Errorf("by_quantity doit être trié par total_orders décroissant: rupture à l'index %d", i) + } + } + if highIdx == -1 || lowIdx == -1 { + t.Fatalf("les deux produits de test doivent apparaître dans by_quantity: highIdx=%d lowIdx=%d", highIdx, lowIdx) + } + if highIdx >= lowIdx { + t.Errorf("le produit avec le plus de commandes doit apparaître avant: highIdx=%d lowIdx=%d", highIdx, lowIdx) + } +} + +func TestOrdersAndRevenueByHour_CountsNonCancelledRevenueOnlyApproved(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_hour") + insertStatsCommand(t, username, "approved", "2024-01-08 14:00:00") + insertStatsCommand(t, username, "pending", "2024-01-08 14:30:00") + insertStatsCommand(t, username, "cancelled", "2024-01-08 14:45:00") + testDB.GDB.Exec(`UPDATE commandes SET total_prix = 25 WHERE username = ? AND status = 'approved'`, username) + + var hourRows []models.HourRow + if err := testDB.OrdersAndRevenueByHour(&hourRows, testDB.ReadResetAt("stats_reset_heures_at")); err != nil { + t.Fatalf("OrdersAndRevenueByHour: %v", err) + } + var found bool + for _, r := range hourRows { + if r.Hour == 14 { + found = true + if r.Count != 2 { + t.Errorf("count à 14h doit exclure la commande annulée: got=%d want=2", r.Count) + } + if r.Revenue != 25 { + t.Errorf("revenue à 14h ne doit compter que les commandes approuvées: got=%.2f want=25", r.Revenue) + } + } + } + if !found { + t.Fatal("aucune ligne pour l'heure 14 trouvée") + } +} diff --git a/backend/gestion/tests/stats_coverage_test.go b/backend/gestion/tests/stats_coverage_test.go new file mode 100644 index 00000000..6f0bc3df --- /dev/null +++ b/backend/gestion/tests/stats_coverage_test.go @@ -0,0 +1,59 @@ +package tests + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +func statsSectionContext(section string) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/stats/reset", nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Params = gin.Params{{Key: "section", Value: section}} + return c, rec +} + +func TestResetAdminStats_RejectsInvalidSection(t *testing.T) { + c, rec := statsSectionContext("section-inexistante") + handlers.ResetAdminStats(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("section invalide doit retourner 400: got=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestResetAdminStats_SuccessResetsSection(t *testing.T) { + c, rec := statsSectionContext("commandes") + handlers.ResetAdminStats(c) + if rec.Code != http.StatusOK { + t.Fatalf("reset d'une section valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + if !bytes.Contains(rec.Body.Bytes(), []byte(`"success":true`)) { + t.Errorf("réponse doit indiquer success: body=%s", rec.Body.String()) + } + resetAt := testDB.ReadResetAt("stats_reset_commandes_at") + if resetAt.IsZero() { + t.Error("le timestamp de reset doit être renseigné après ResetAdminStats") + } +} + +func TestGetMyDeliveryStats_RejectsNonLivreurRole(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/livreur/stats", nil) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", testUserPrefix+"stats_role") + c.Set("role", "client") + handlers.GetMyDeliveryStats(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role client doit être refusé: got=%d want=403", rec.Code) + } +} diff --git a/backend/gestion/tests/stats_reset_test.go b/backend/gestion/tests/stats_reset_test.go new file mode 100644 index 00000000..c38fdef4 --- /dev/null +++ b/backend/gestion/tests/stats_reset_test.go @@ -0,0 +1,104 @@ +package tests + +import ( + "testing" + "time" +) + +// ResetAdminStat déplace le point de coupure temporel utilisé par toutes les +// requêtes de stats (TotalOrders, TotalRevenue, ActiveDaysLast30, ...) : les +// commandes créées AVANT le reset doivent disparaître des totaux, celles +// créées APRÈS doivent rester visibles. C'est le mécanisme central derrière +// le bouton "reset stats" de l'admin — jamais testé jusqu'ici. + +const statsResetTestKey = "stats_reset_commandes_at" + +func cleanupStatsResetKey(t *testing.T, key string) { + t.Helper() + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM app_settings WHERE key = ?`, key) + }) +} + +func TestResetAdminStat_TotalOrdersExcludesOrdersBeforeReset(t *testing.T) { + cleanupStockTestData(t) + cleanupStatsResetKey(t, statsResetTestKey) + username := newTestClient(t, "stat_reset_orders") + productID := newTestProduct(t, "StatResetOrders", 10) + + newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + time.Sleep(1100 * time.Millisecond) // RFC3339 stocké sans fraction de seconde : marge nécessaire + + if err := testDB.ResetAdminStat(statsResetTestKey); err != nil { + t.Fatalf("ResetAdminStat: %v", err) + } + time.Sleep(1100 * time.Millisecond) + + newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + + totalWithoutFilter, err := testDB.TotalOrders(time.Time{}) + if err != nil { + t.Fatalf("TotalOrders(zero): %v", err) + } + if totalWithoutFilter != 2 { + t.Fatalf("précondition: 2 commandes doivent exister sans filtre: got=%d", totalWithoutFilter) + } + + resetAt := testDB.ReadResetAt(statsResetTestKey) + if resetAt.IsZero() { + t.Fatal("ReadResetAt ne doit pas être zero après ResetAdminStat") + } + + totalAfterReset, err := testDB.TotalOrders(resetAt) + if err != nil { + t.Fatalf("TotalOrders(resetAt): %v", err) + } + if totalAfterReset != 1 { + t.Errorf("après reset, seule la commande créée après doit être comptée: got=%d want=1", totalAfterReset) + } +} + +// Chaque section de stats a sa propre clé de reset (commandes, revenus, +// produits, heures, jours, doses) — réinitialiser l'une ne doit jamais +// affecter les autres. +func TestResetAdminStat_DifferentSectionsAreIndependent(t *testing.T) { + cleanupStatsResetKey(t, "stats_reset_commandes_at_test_indep") + cleanupStatsResetKey(t, "stats_reset_revenus_at_test_indep") + + if err := testDB.ResetAdminStat("stats_reset_commandes_at_test_indep"); err != nil { + t.Fatalf("ResetAdminStat (commandes): %v", err) + } + + if got := testDB.ReadResetAt("stats_reset_revenus_at_test_indep"); !got.IsZero() { + t.Errorf("réinitialiser la section commandes ne doit pas créer de reset pour revenus: got=%v", got) + } + if got := testDB.ReadResetAt("stats_reset_commandes_at_test_indep"); got.IsZero() { + t.Error("la section commandes doit bien avoir une date de reset") + } +} + +// ActiveDaysLast30 (stat secondaire) doit respecter le même filtre de reset +// que TotalOrders : un jour dont l'unique commande a été passée avant le +// reset ne doit plus compter comme jour actif. +func TestActiveDaysLast30_RespectsResetFilter(t *testing.T) { + cleanupStockTestData(t) + cleanupStatsResetKey(t, statsResetTestKey) + username := newTestClient(t, "stat_reset_active_days") + productID := newTestProduct(t, "StatResetActiveDays", 10) + + newTestCommandWithItem(t, username, "pending", "", productID, 1, 10) + time.Sleep(1100 * time.Millisecond) + + if err := testDB.ResetAdminStat(statsResetTestKey); err != nil { + t.Fatalf("ResetAdminStat: %v", err) + } + resetAt := testDB.ReadResetAt(statsResetTestKey) + + activeDays, err := testDB.ActiveDaysLast30(resetAt) + if err != nil { + t.Fatalf("ActiveDaysLast30: %v", err) + } + if activeDays != 0 { + t.Errorf("aucun jour actif ne doit être compté (seule commande antérieure au reset): got=%d want=0", activeDays) + } +} diff --git a/backend/gestion/tests/stats_test.go b/backend/gestion/tests/stats_test.go new file mode 100644 index 00000000..e3df2d4b --- /dev/null +++ b/backend/gestion/tests/stats_test.go @@ -0,0 +1,208 @@ +package tests + +import ( + "gestion/models" + "testing" + "time" +) + +// newTestOrderForStats crée directement une commande (+ un item) avec un +// statut, un total, un crédit de parrainage utilisé et une date de création +// contrôlés, pour tester isolément les calculs de stats admin. +func newTestOrderForStats(t *testing.T, username, status string, productID int, quantite, prix, referralUsed float64, createdAt time.Time) int { + t.Helper() + var cmdID int + if err := testDB.GDB.Raw( + `INSERT INTO commandes (username, status, adresse, total_prix, referral_used, created_at, updated_at) + VALUES (?, ?, 'Adresse test', ?, ?, ?, ?) RETURNING id`, + username, status, prix, referralUsed, createdAt, createdAt, + ).Scan(&cmdID).Error; err != nil { + t.Fatalf("création commande stats test: %v", err) + } + if err := testDB.GDB.Exec( + `INSERT INTO command_items (command_id, product_id, produit, quantite, prix, status) + VALUES (?, ?, 'item test', ?, ?, 'pending')`, + cmdID, productID, quantite, prix, + ).Error; err != nil { + t.Fatalf("création item stats test: %v", err) + } + return cmdID +} + +// TotalRevenue ne compte que les commandes approuvées, nettes du crédit de +// parrainage utilisé — les commandes annulées ou encore en cours sont exclues. +func TestTotalRevenue_NetsReferralAndExcludesNonApproved(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_total_revenue") + productID := newTestProduct(t, "StatsTotalRevenue", 100) + now := time.Now() + + newTestOrderForStats(t, username, "approved", productID, 5, 100, 30, now) // net 70 + newTestOrderForStats(t, username, "approved", productID, 2, 50, 0, now) // net 50 + newTestOrderForStats(t, username, "cancelled", productID, 9, 999, 0, now) // exclue + newTestOrderForStats(t, username, "pending", productID, 9, 999, 0, now) // exclue (pas encore approuvée) + + total, err := testDB.TotalRevenue(time.Time{}) + if err != nil { + t.Fatalf("TotalRevenue: %v", err) + } + if total != 120 { + t.Errorf("TotalRevenue: got=%.2f want=120 (70+50, parrainage déduit, annulée/pending exclues)", total) + } +} + +// TotalOrders exclut uniquement les commandes annulées (contrairement à +// TotalRevenue, il compte aussi les commandes non encore approuvées). +func TestTotalOrders_ExcludesOnlyCancelled(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_total_orders") + productID := newTestProduct(t, "StatsTotalOrders", 100) + now := time.Now() + + newTestOrderForStats(t, username, "pending", productID, 1, 10, 0, now) + newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, now) + newTestOrderForStats(t, username, "cancelled", productID, 1, 10, 0, now) + + total, err := testDB.TotalOrders(time.Time{}) + if err != nil { + t.Fatalf("TotalOrders: %v", err) + } + if total != 2 { + t.Errorf("TotalOrders: got=%d want=2 (pending+approved, cancelled exclue)", total) + } +} + +// Régression du bug corrigé : le revenu par produit/catégorie (TopProducts, +// QuantityBreakdown, DailyProductDetailForDate) doit rester cohérent avec +// TotalRevenue même quand une commande approuvée a utilisé du crédit de +// parrainage — avant le fix, ces trois vues sommaient ci.prix brut sans +// déduire referral_used, produisant un total supérieur au résumé global. +func TestProductBreakdowns_RevenueMatchesTotalRevenue_WithReferralUsed(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_breakdown_referral") + productA := newTestProduct(t, "StatsBreakdownA", 100) + productB := newTestProduct(t, "StatsBreakdownB", 100) + now := time.Now() + + newTestOrderForStats(t, username, "approved", productA, 5, 100, 30, now) // net 70 + newTestOrderForStats(t, username, "approved", productB, 2, 50, 0, now) // net 50 + + totalRevenue, err := testDB.TotalRevenue(time.Time{}) + if err != nil { + t.Fatalf("TotalRevenue: %v", err) + } + if totalRevenue != 120 { + t.Fatalf("précondition TotalRevenue: got=%.2f want=120", totalRevenue) + } + + var prodRows []models.ProductRow + if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil { + t.Fatalf("TopProducts: %v", err) + } + sumTop := 0.0 + for _, r := range prodRows { + sumTop += r.Revenue + } + if sumTop != totalRevenue { + t.Errorf("somme TopProducts.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumTop, totalRevenue) + } + + var qtyRows []models.QuantityBreakdownRow + if err := testDB.QuantityBreakdown(&qtyRows, time.Time{}); err != nil { + t.Fatalf("QuantityBreakdown: %v", err) + } + sumQty := 0.0 + for _, r := range qtyRows { + sumQty += r.Revenue + } + if sumQty != totalRevenue { + t.Errorf("somme QuantityBreakdown.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumQty, totalRevenue) + } + + var dailyRows []models.DailyProductRow + if err := testDB.DailyProductDetailForDate(&dailyRows, now); err != nil { + t.Fatalf("DailyProductDetailForDate: %v", err) + } + sumDaily := 0.0 + for _, r := range dailyRows { + sumDaily += r.Revenue + } + if sumDaily != totalRevenue { + t.Errorf("somme DailyProductDetailForDate.revenue = %.2f, doit correspondre à TotalRevenue = %.2f", sumDaily, totalRevenue) + } +} + +// Cas limite : commande entièrement couverte par le crédit de parrainage +// (total_prix == referral_used) — la part de revenu attribuée à l'article +// doit être 0, sans division par zéro ni erreur SQL. +func TestProductBreakdowns_FullyCoveredByReferralYieldsZeroRevenue(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_breakdown_full_referral") + productID := newTestProduct(t, "StatsBreakdownFullReferral", 100) + now := time.Now() + + newTestOrderForStats(t, username, "approved", productID, 4, 40, 40, now) + + var prodRows []models.ProductRow + if err := testDB.TopProducts(&prodRows, time.Time{}, 15); err != nil { + t.Fatalf("TopProducts: %v", err) + } + if len(prodRows) != 1 { + t.Fatalf("attendu 1 produit, got=%d", len(prodRows)) + } + if prodRows[0].Revenue != 0 { + t.Errorf("revenu attendu à 0 pour une commande entièrement couverte par le parrainage: got=%.2f", prodRows[0].Revenue) + } +} + +// RevenueByDayLast30 doit rester cohérent avec TotalRevenue pour des +// commandes créées aujourd'hui (dans la fenêtre des 30 derniers jours). +func TestRevenueByDayLast30_MatchesTotalRevenue(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_revenue_by_day") + productID := newTestProduct(t, "StatsRevenueByDay", 100) + now := time.Now() + + newTestOrderForStats(t, username, "approved", productID, 3, 90, 20, now) // net 70 + + totalRevenue, err := testDB.TotalRevenue(time.Time{}) + if err != nil { + t.Fatalf("TotalRevenue: %v", err) + } + + var dayRevRows []models.DayRevenueRow + if err := testDB.RevenueByDayLast30(&dayRevRows, time.Time{}); err != nil { + t.Fatalf("RevenueByDayLast30: %v", err) + } + sum := 0.0 + for _, r := range dayRevRows { + sum += r.Revenue + } + if sum != totalRevenue { + t.Errorf("somme RevenueByDayLast30 = %.2f, doit correspondre à TotalRevenue = %.2f", sum, totalRevenue) + } +} + +// DailyProductDetailForDate ne doit inclure que les commandes du jour demandé. +func TestDailyProductDetailForDate_OnlyIncludesGivenDate(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "stats_daily_date_filter") + productID := newTestProduct(t, "StatsDailyDateFilter", 100) + today := time.Now() + yesterday := today.AddDate(0, 0, -1) + + newTestOrderForStats(t, username, "approved", productID, 1, 10, 0, today) + newTestOrderForStats(t, username, "approved", productID, 1, 20, 0, yesterday) + + var rows []models.DailyProductRow + if err := testDB.DailyProductDetailForDate(&rows, today); err != nil { + t.Fatalf("DailyProductDetailForDate: %v", err) + } + sum := 0.0 + for _, r := range rows { + sum += r.Revenue + } + if sum != 10 { + t.Errorf("revenu du jour ne doit inclure que la commande d'aujourd'hui: got=%.2f want=10", sum) + } +} diff --git a/backend/gestion/tests/stock_cancel_test.go b/backend/gestion/tests/stock_cancel_test.go new file mode 100644 index 00000000..5082f947 --- /dev/null +++ b/backend/gestion/tests/stock_cancel_test.go @@ -0,0 +1,284 @@ +package tests + +import ( + "sync" + "testing" +) + +// ── Client (CancelCommandAtomic) ──────────────────────────────────────────── + +func TestCancelCommandAtomic_RefundsStockExactly(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_client_ok") + productID := newTestProduct(t, "CancelClientOk", 5) + // pending, sans livreur assigné -> annulation sans pénalité ni confirmation requise + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) + + if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err != nil { + t.Fatalf("CancelCommandAtomic: %v", err) + } + + if got := productStock(t, productID); got != 8 { + t.Errorf("stock après annulation client (5 initial + 3 remboursés): got=%.2f want=8", got) + } + if got := commandStatus(t, cmdID); got != "cancelled" { + t.Errorf("statut après annulation: got=%s want=cancelled", got) + } +} + +func TestCancelCommandAtomic_DoubleCancelDoesNotDoubleRefund(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_client_dbl") + productID := newTestProduct(t, "CancelClientDbl", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) + + if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err != nil { + t.Fatalf("1er CancelCommandAtomic: %v", err) + } + // Rejeu (double-tap / retry réseau) : doit échouer proprement, pas de second remboursement. + if _, err := testDB.CancelCommandAtomic(cmdID, username, "test", false); err == nil { + t.Fatal("le second appel sur une commande déjà annulée doit renvoyer une erreur") + } + + if got := productStock(t, productID); got != 8 { + t.Errorf("stock après double annulation (doit rester remboursé une seule fois): got=%.2f want=8", got) + } +} + +func TestCancelCommandAtomic_ConcurrentCancelDoesNotDoubleRefund(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_client_concurrent") + productID := newTestProduct(t, "CancelClientConcurrent", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) + + var wg sync.WaitGroup + results := make([]error, 3) + for i := range 3 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, results[idx] = testDB.CancelCommandAtomic(cmdID, username, "test", false) + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range results { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("un seul appel concurrent doit réussir l'annulation: got=%d succès", successCount) + } + if got := productStock(t, productID); got != 8 { + t.Errorf("stock après 3 annulations concurrentes de la même commande: got=%.2f want=8 (un seul remboursement)", got) + } +} + +// ── Livreur (CancelDeliveryByLivreurAtomic) ───────────────────────────────── + +func TestCancelDeliveryByLivreurAtomic_RefundsStockExactly(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_livreur_ok") + productID := newTestProduct(t, "CancelLivreurOk", 5) + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurX", productID, 2, 20) + + alreadyCancelled, prevStatus, err := testDB.CancelDeliveryByLivreurAtomic(cmdID) + if err != nil { + t.Fatalf("CancelDeliveryByLivreurAtomic: %v", err) + } + if alreadyCancelled { + t.Error("alreadyCancelled ne doit pas être true au premier appel") + } + if prevStatus != "en_route" { + t.Errorf("prevStatus: got=%s want=en_route", prevStatus) + } + + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après annulation livreur (5 initial + 2 remboursés): got=%.2f want=7", got) + } + if got := commandStatus(t, cmdID); got != "cancelled" { + t.Errorf("statut après annulation livreur: got=%s want=cancelled", got) + } +} + +func TestCancelDeliveryByLivreurAtomic_DoubleCancelIsIdempotent(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_livreur_dbl") + productID := newTestProduct(t, "CancelLivreurDbl", 5) + cmdID := newTestCommandWithItem(t, username, "arrived", testUserPrefix+"livreurX", productID, 2, 20) + + if _, _, err := testDB.CancelDeliveryByLivreurAtomic(cmdID); err != nil { + t.Fatalf("1er appel: %v", err) + } + + alreadyCancelled, _, err := testDB.CancelDeliveryByLivreurAtomic(cmdID) + if err != nil { + t.Fatalf("2e appel (doit être idempotent, pas une erreur): %v", err) + } + if !alreadyCancelled { + t.Error("le 2e appel doit renvoyer alreadyCancelled=true") + } + + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après double annulation livreur (doit rester remboursé une seule fois): got=%.2f want=7", got) + } +} + +func TestCancelDeliveryByLivreurAtomic_ConcurrentCancelDoesNotDoubleRefund(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_livreur_concurrent") + productID := newTestProduct(t, "CancelLivreurConcurrent", 5) + cmdID := newTestCommandWithItem(t, username, "en_route", testUserPrefix+"livreurX", productID, 2, 20) + + var wg sync.WaitGroup + n := 3 + alreadyFlags := make([]bool, n) + errs := make([]error, n) + for i := range n { + wg.Add(1) + go func(idx int) { + defer wg.Done() + alreadyFlags[idx], _, errs[idx] = testDB.CancelDeliveryByLivreurAtomic(cmdID) + }(i) + } + wg.Wait() + + freshCancelCount := 0 + for i := range n { + if errs[i] != nil { + t.Errorf("appel %d: erreur inattendue: %v", i, errs[i]) + continue + } + if !alreadyFlags[i] { + freshCancelCount++ + } + } + if freshCancelCount != 1 { + t.Errorf("un seul appel concurrent doit effectuer l'annulation réelle: got=%d", freshCancelCount) + } + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après annulations concurrentes livreur: got=%.2f want=7 (un seul remboursement)", got) + } +} + +// ── Admin/Cabine (DeleteCommandAtomic) ────────────────────────────────────── + +func TestDeleteCommandAtomic_RefundsStockAndRemovesCommand(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delete_admin_ok") + productID := newTestProduct(t, "DeleteAdminOk", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 4, 40) + + if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil { + t.Fatalf("DeleteCommandAtomic: %v", err) + } + + if got := productStock(t, productID); got != 9 { + t.Errorf("stock après suppression admin (5 initial + 4 remboursés): got=%.2f want=9", got) + } + + var count int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE id = ?`, cmdID).Scan(&count) + if count != 0 { + t.Errorf("la commande doit être supprimée de la base: got=%d lignes restantes", count) + } +} + +// Si la commande est déjà annulée ou approuvée, le stock a déjà été traité +// par le chemin correspondant — DeleteCommandAtomic ne doit pas rembourser +// une seconde fois lors d'une suppression a posteriori. +func TestDeleteCommandAtomic_DoesNotRefundAlreadyCancelledOrApprovedOrder(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delete_admin_already") + productID := newTestProduct(t, "DeleteAdminAlready", 5) + cmdID := newTestCommandWithItem(t, username, "cancelled", "", productID, 4, 40) + + if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil { + t.Fatalf("DeleteCommandAtomic: %v", err) + } + + if got := productStock(t, productID); got != 5 { + t.Errorf("stock ne doit pas être remboursé pour une commande déjà annulée: got=%.2f want=5", got) + } +} + +// Régression: une commande "livrée" (marchandise déjà sortie du stock au +// checkout, remise au client) ne doit pas voir son stock remboursé lors +// d'une suppression — symétrique à CancelCommandByAdminAtomic qui exclut +// déjà "livre" de son remboursement. +func TestDeleteCommandAtomic_DoesNotRefundDeliveredOrder(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delete_admin_livre") + productID := newTestProduct(t, "DeleteAdminLivre", 5) + cmdID := newTestCommandWithItem(t, username, "livre", "", productID, 4, 40) + + if err := testDB.DeleteCommandAtomic(cmdID, "admin_test", "admin"); err != nil { + t.Fatalf("DeleteCommandAtomic: %v", err) + } + + if got := productStock(t, productID); got != 5 { + t.Errorf("stock ne doit pas être remboursé pour une commande déjà livrée: got=%.2f want=5", got) + } +} + +func TestDeleteCommandAtomic_UnknownCommandReturnsError(t *testing.T) { + cleanupStockTestData(t) + err := testDB.DeleteCommandAtomic(99999999, "admin_test", "admin") + if err == nil { + t.Fatal("DeleteCommandAtomic sur une commande inexistante doit retourner une erreur") + } +} + +// ── Crypto (CancelCryptoCommand) ──────────────────────────────────────────── + +func TestCancelCryptoCommand_RefundsStockOnPendingPayment(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_crypto_ok") + productID := newTestProduct(t, "CancelCryptoOk", 5) + cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20) + + if err := testDB.CancelCryptoCommand(cmdID); err != nil { + t.Fatalf("CancelCryptoCommand: %v", err) + } + + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après annulation crypto (5 initial + 2 remboursés): got=%.2f want=7", got) + } + if got := commandStatus(t, cmdID); got != "cancelled" { + t.Errorf("statut après annulation crypto: got=%s want=cancelled", got) + } +} + +func TestCancelCryptoCommand_RejectsIfNotPendingPayment(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_crypto_wrong") + productID := newTestProduct(t, "CancelCryptoWrong", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20) + + if err := testDB.CancelCryptoCommand(cmdID); err == nil { + t.Fatal("attendu une erreur : seule une commande pending_payment est annulable via ce chemin") + } + if got := productStock(t, productID); got != 5 { + t.Errorf("stock ne doit pas bouger si le statut n'est pas pending_payment: got=%.2f want=5", got) + } +} + +func TestCancelCryptoCommand_DoubleCancelDoesNotDoubleRefund(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "cancel_crypto_dbl") + productID := newTestProduct(t, "CancelCryptoDbl", 5) + cmdID := newTestCommandWithItem(t, username, "pending_payment", "", productID, 2, 20) + + if err := testDB.CancelCryptoCommand(cmdID); err != nil { + t.Fatalf("1er appel: %v", err) + } + if err := testDB.CancelCryptoCommand(cmdID); err == nil { + t.Fatal("le 2e appel (webhook rejoué) doit échouer, pas rembourser une seconde fois") + } + + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après webhook rejoué: got=%.2f want=7 (un seul remboursement)", got) + } +} diff --git a/backend/gestion/tests/stock_checkout_test.go b/backend/gestion/tests/stock_checkout_test.go new file mode 100644 index 00000000..3e8fd72a --- /dev/null +++ b/backend/gestion/tests/stock_checkout_test.go @@ -0,0 +1,200 @@ +package tests + +import ( + "sync" + "testing" +) + +// AddToBasket vérifie le stock disponible mais ne le décrémente jamais — +// le stock réel n'est consommé qu'au checkout (voir comprehension-metier). +func TestAddToBasket_RejectsInsufficientStock(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "addbasket_insuff") + productID := newTestProduct(t, "AddBasketInsuff", 2) + + _, err := testDB.AddToBasket(username, productID, 3) + if err == nil { + t.Fatal("attendu une erreur (stock insuffisant), reçu nil") + } + + if got := productStock(t, productID); got != 2 { + t.Errorf("stock ne doit pas bouger sur un ajout panier refusé: got=%.2f want=2", got) + } +} + +func TestAddToBasket_DoesNotDecrementStock(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "addbasket_ok") + productID := newTestProduct(t, "AddBasketOk", 10) + + if _, err := testDB.AddToBasket(username, productID, 4); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + + if got := productStock(t, productID); got != 10 { + t.Errorf("le stock ne doit être décrémenté qu'au checkout, pas à l'ajout panier: got=%.2f want=10", got) + } +} + +// Le checkout (CreateCommandWithAddress) doit décrémenter le stock exactement +// de la quantité commandée, dans la même transaction que la création de la +// commande et le vidage du panier. +func TestCheckout_DecrementsStockExactly(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "checkout_ok") + productID := newTestProduct(t, "CheckoutOk", 10) + + if _, err := testDB.AddToBasket(username, productID, 3); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + if got := productStock(t, productID); got != 7 { + t.Errorf("stock après checkout de 3 unités sur 10: got=%.2f want=7", got) + } + + var basketCount int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount) + if basketCount != 0 { + t.Errorf("le panier doit être vidé après checkout, reste %d article(s)", basketCount) + } + + var status string + testDB.GDB.Raw(`SELECT status FROM commandes WHERE id = ?`, cmd.ID).Scan(&status) + if status != "pending" { + t.Errorf("statut de la commande créée: got=%s want=pending", status) + } +} + +// Règle métier : les articles récompense (is_reward=true) sont des produits +// physiques réellement distribués et doivent décrémenter le stock exactement +// comme un article payant — jamais exclus du décompte. +func TestCheckout_RewardItemDecrementsStockLikeAPaidItem(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "checkout_reward") + paidProductID := newTestProduct(t, "CheckoutRewardPaid", 10) + rewardProductID := newTestProduct(t, "CheckoutRewardFree", 5) + + if _, err := testDB.AddToBasket(username, paidProductID, 2); err != nil { + t.Fatalf("AddToBasket (payant): %v", err) + } + // Article récompense : inséré directement (produit par ClaimMyReward en + // production), prix affiché 0€, mais le stock doit être traité pareil. + if err := testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at) + VALUES (?, ?, 2, 0, true, 'pool_0', CURRENT_TIMESTAMP)`, + username, rewardProductID, + ).Error; err != nil { + t.Fatalf("insertion article récompense: %v", err) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + if got := productStock(t, paidProductID); got != 8 { + t.Errorf("stock produit payant après checkout: got=%.2f want=8", got) + } + if got := productStock(t, rewardProductID); got != 3 { + t.Errorf("stock produit récompense après checkout (doit décrémenter comme un article payant): got=%.2f want=3", got) + } +} + +// Un stock insuffisant sur un seul article du panier doit faire échouer tout +// le checkout, sans décrémenter partiellement les autres articles ni créer de +// commande fantôme (le tout est dans une seule transaction). +func TestCheckout_InsufficientStockOnOneItemRollsBackEverything(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "checkout_partial") + okProductID := newTestProduct(t, "CheckoutPartialOk", 10) + shortProductID := newTestProduct(t, "CheckoutPartialShort", 1) + + if _, err := testDB.AddToBasket(username, okProductID, 5); err != nil { + t.Fatalf("AddToBasket (ok): %v", err) + } + // Second article : on force un panier dont la quantité dépasse le stock + // disponible au moment du checkout (simulation d'une désynchronisation, + // par ex. deux clients ayant chacun ajouté le dernier article en stock). + if err := testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) + VALUES (?, ?, 5, 50, false, CURRENT_TIMESTAMP)`, + username, shortProductID, + ).Error; err != nil { + t.Fatalf("insertion panier insuffisant: %v", err) + } + + before := productStock(t, okProductID) + + _, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err == nil { + t.Fatal("attendu un échec de checkout (stock insuffisant), reçu nil") + } + + if got := productStock(t, okProductID); got != before { + t.Errorf("le stock du 1er article ne doit pas être décrémenté si le 2e échoue (pas de décrément partiel): got=%.2f want=%.2f", got, before) + } + if got := productStock(t, shortProductID); got != 1 { + t.Errorf("stock du produit en rupture ne doit pas bouger: got=%.2f want=1", got) + } + + var basketCount int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ?`, username).Scan(&basketCount) + if basketCount != 2 { + t.Errorf("le panier ne doit pas être vidé si le checkout échoue: got=%d want=2", basketCount) + } + + var cmdCount int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount) + if cmdCount != 0 { + t.Errorf("aucune commande fantôme ne doit être créée si le checkout échoue: got=%d want=0", cmdCount) + } +} + +// Deux checkouts quasi simultanés pour le même client (double-tap / retry +// réseau) sur un stock tout juste suffisant pour une seule commande ne +// doivent décrémenter le stock qu'une seule fois — le verrou FOR UPDATE sur +// le panier sérialise les deux tentatives. +func TestCheckout_ConcurrentDoubleSubmitDoesNotOversell(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "checkout_concurrent") + productID := newTestProduct(t, "CheckoutConcurrent", 2) + + if _, err := testDB.AddToBasket(username, productID, 2); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + + var wg sync.WaitGroup + results := make([]error, 2) + for i := range 2 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + _, results[idx] = testDB.CreateCommandWithAddress(username, "1 rue de test") + }(i) + } + wg.Wait() + + successCount := 0 + for _, err := range results { + if err == nil { + successCount++ + } + } + if successCount != 1 { + t.Errorf("exactement 1 des 2 checkouts concurrents doit réussir (panier vidé par le premier): got=%d succès", successCount) + } + + if got := productStock(t, productID); got != 0 { + t.Errorf("stock final après un seul checkout réussi de 2 unités sur 2: got=%.2f want=0", got) + } + + var cmdCount int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE username = ?`, username).Scan(&cmdCount) + if cmdCount != 1 { + t.Errorf("une seule commande doit avoir été créée: got=%d want=1", cmdCount) + } +} diff --git a/backend/gestion/tests/stock_coverage_test.go b/backend/gestion/tests/stock_coverage_test.go new file mode 100644 index 00000000..678b2b05 --- /dev/null +++ b/backend/gestion/tests/stock_coverage_test.go @@ -0,0 +1,428 @@ +package tests + +import ( + "bytes" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gestion/handlers" + + "github.com/gin-gonic/gin" +) + +// ── Helpers ────────────────────────────────────────────────────────────── + +// newTestCategory crée une catégorie de test et programme son nettoyage. +// CreateProduct/UpdateProduct passent la catégorie reçue par strings.ToLower +// avant de vérifier son existence : le nom doit donc déjà être en +// minuscules ici pour matcher. +func newTestCategory(t *testing.T, name string) string { + t.Helper() + fullName := strings.ToLower(testProductPrefix + name) + if _, err := testDB.CreateCategory(fullName, "#000000", false); err != nil { + t.Fatalf("CreateCategory %q: %v", fullName, err) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM categories WHERE name = ?`, fullName) + }) + return fullName +} + +// productCreateRequest construit une requête multipart minimale pour +// CreateProduct (un seul prix, pas de média). +func productCreateRequest(fields map[string]string) (*bytes.Buffer, string) { + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + for k, v := range fields { + w.WriteField(k, v) + } + w.Close() + return body, w.FormDataContentType() +} + +func productContext(role string, body *bytes.Buffer, contentType string, productID int) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/products", body) + req.Header.Set("Content-Type", contentType) + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", testUserPrefix+"product_admin") + c.Set("role", role) + if productID != 0 { + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}} + } + return c, rec +} + +func jsonStockContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products/stock", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", testUserPrefix+"product_admin") + c.Set("role", role) + if productID != 0 { + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}} + } + return c, rec +} + +// ── CreateProduct ──────────────────────────────────────────────────────── + +func TestCreateProduct_RejectsNonAdminNonCabine(t *testing.T) { + cleanupStockTestData(t) + body, ct := productCreateRequest(map[string]string{ + "name": testProductPrefix + "CP1", "category": "x", "description": "d", + "stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5", + }) + c, rec := productContext("client", body, ct, 0) + handlers.CreateProduct(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role client doit être refusé: got=%d want=403", rec.Code) + } +} + +func TestCreateProduct_RejectsNegativeStock(t *testing.T) { + cleanupStockTestData(t) + cat := newTestCategory(t, "CatNeg") + body, ct := productCreateRequest(map[string]string{ + "name": testProductPrefix + "CPNeg", "category": cat, "description": "d", + "stock": "-5", "prices[0][quantity]": "1", "prices[0][price]": "5", + }) + c, rec := productContext("admin", body, ct, 0) + handlers.CreateProduct(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCreateProduct_RejectsStockOverMax(t *testing.T) { + cleanupStockTestData(t) + cat := newTestCategory(t, "CatOver") + body, ct := productCreateRequest(map[string]string{ + "name": testProductPrefix + "CPOver", "category": cat, "description": "d", + "stock": "1000001", "prices[0][quantity]": "1", "prices[0][price]": "5", + }) + c, rec := productContext("admin", body, ct, 0) + handlers.CreateProduct(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCreateProduct_RejectsUnknownCategory(t *testing.T) { + cleanupStockTestData(t) + body, ct := productCreateRequest(map[string]string{ + "name": testProductPrefix + "CPCat", "category": "categorie-inexistante-xyz", "description": "d", + "stock": "10", "prices[0][quantity]": "1", "prices[0][price]": "5", + }) + c, rec := productContext("admin", body, ct, 0) + handlers.CreateProduct(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("catégorie inconnue doit être rejetée: got=%d want=400 body=%s", rec.Code, rec.Body.String()) + } +} + +func TestCreateProduct_SuccessCreatesProductAndPrice(t *testing.T) { + cleanupStockTestData(t) + cat := newTestCategory(t, "CatOk") + body, ct := productCreateRequest(map[string]string{ + "name": testProductPrefix + "CPOk", "category": cat, "description": "d", + "stock": "42", "prices[0][quantity]": "1", "prices[0][price]": "9.99", + }) + c, rec := productContext("admin", body, ct, 0) + handlers.CreateProduct(c) + if rec.Code != http.StatusCreated { + t.Fatalf("création produit valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + + var count int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE name = ?`, testProductPrefix+"CPOk").Scan(&count) + if count != 1 { + t.Errorf("le produit doit être créé en base: got=%d", count) + } + var priceCount int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM product_prices pp JOIN products p ON p.id = pp.product_id WHERE p.name = ?`, testProductPrefix+"CPOk").Scan(&priceCount) + if priceCount != 1 { + t.Errorf("le prix doit être créé en base: got=%d", priceCount) + } + t.Cleanup(func() { + testDB.GDB.Exec(`DELETE FROM product_prices WHERE product_id IN (SELECT id FROM products WHERE name = ?)`, testProductPrefix+"CPOk") + testDB.GDB.Exec(`DELETE FROM products WHERE name = ?`, testProductPrefix+"CPOk") + }) +} + +// ── UpdateStock ────────────────────────────────────────────────────────── + +func TestUpdateStock_RejectsNonAdmin(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "USNonAdmin", 10) + c, rec := jsonStockContext("cabine", []byte(`{"stock":20}`), productID) + handlers.UpdateStock(c) + if rec.Code != http.StatusForbidden { + t.Errorf("role cabine doit être refusé pour UpdateStock: got=%d want=403", rec.Code) + } +} + +func TestUpdateStock_RejectsNegativeStock(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "USNeg", 10) + c, rec := jsonStockContext("admin", []byte(`{"stock":-1}`), productID) + handlers.UpdateStock(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("stock négatif doit être rejeté: got=%d want=400 body=%s", rec.Code, rec.Body.String()) + } + if got := productStock(t, productID); got != 10 { + t.Errorf("le stock ne doit pas changer après un rejet: got=%.2f want=10", got) + } +} + +func TestUpdateStock_RejectsStockOverMax(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "USOver", 10) + c, rec := jsonStockContext("admin", []byte(`{"stock":1000001}`), productID) + handlers.UpdateStock(c) + if rec.Code != http.StatusBadRequest { + t.Errorf("stock > 1000000 doit être rejeté: got=%d want=400", rec.Code) + } +} + +// Documente un bug existant plutôt que le comportement souhaité : +// GetProductByID fait un Raw(...).Scan() qui ne retourne PAS d'erreur +// quand aucune ligne ne matche (contrairement à First()), donc le check +// "produit non trouvé → 404" en tête de UpdateStock ne se déclenche +// jamais. La requête continue jusqu'à SetProductStock, qui échoue côté +// DB et remonte en 500 générique au lieu d'un 404 propre. +func TestUpdateStock_UnknownProductReturns500NotFoundBugDocumented(t *testing.T) { + cleanupStockTestData(t) + c, rec := jsonStockContext("admin", []byte(`{"stock":5}`), 99999999) + handlers.UpdateStock(c) + if rec.Code != http.StatusInternalServerError { + t.Errorf("comportement actuel (bug): produit inconnu retourne 500, pas 404: got=%d", rec.Code) + } +} + +func TestUpdateStock_SuccessSetsExactValue(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "USOk", 10) + c, rec := jsonStockContext("admin", []byte(`{"stock":33}`), productID) + handlers.UpdateStock(c) + if rec.Code != http.StatusOK { + t.Fatalf("mise à jour stock valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := productStock(t, productID); got != 33 { + t.Errorf("stock après UpdateStock: got=%.2f want=33", got) + } +} + +// Documente le comportement actuel du garde-fou "réservé en panier" de +// UpdateStock : `req.Stock+reserved < reserved` est algébriquement +// équivalent à `req.Stock < 0`, donc ce garde-fou ne bloque en réalité +// jamais un stock positif inférieur à la quantité réservée. Un admin peut +// donc, aujourd'hui, mettre le stock en dessous du réservé en panier — ce +// test documente ce comportement pour éviter une régression silencieuse +// si quelqu'un "corrige" la formule sans le vouloir explicitement. +func TestUpdateStock_ReservedGuardDoesNotBlockStockBelowReserved(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "us_reserved") + productID := newTestProduct(t, "USReserved", 10) + if _, err := testDB.AddToBasket(username, productID, 8); err != nil { + t.Fatalf("AddToBasket: %v", err) + } + // 8 sont réservés en panier ; on met le stock à 1, en dessous du réservé. + c, rec := jsonStockContext("admin", []byte(`{"stock":1}`), productID) + handlers.UpdateStock(c) + if rec.Code != http.StatusOK { + t.Fatalf("comportement actuel: la requête réussit malgré stock < réservé: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := productStock(t, productID); got != 1 { + t.Errorf("stock après UpdateStock: got=%.2f want=1", got) + } +} + +// ── UpdateProduct ──────────────────────────────────────────────────────── + +func updateProductJSONContext(role string, body []byte, productID int) (*gin.Context, *httptest.ResponseRecorder) { + req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/products", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c, _ := gin.CreateTestContext(rec) + c.Request = req + c.Set("database", testDB) + c.Set("username", testUserPrefix+"product_admin") + c.Set("role", role) + c.Params = gin.Params{{Key: "id", Value: fmt.Sprintf("%d", productID)}} + return c, rec +} + +func TestUpdateProduct_UpdatesStockWhenProvided(t *testing.T) { + cleanupStockTestData(t) + cat := newTestCategory(t, "CatUpd") + productID := newTestProduct(t, "UPStock", 5) + + body := []byte(fmt.Sprintf(`{"name":"UPStock","category":%q,"description":"d2","unit":"kg","stock":77,"prices":[{"quantity":1,"price":5}]}`, cat)) + c, rec := updateProductJSONContext("admin", body, productID) + handlers.UpdateProduct(c) + if rec.Code != http.StatusOK { + t.Fatalf("UpdateProduct valide doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + if got := productStock(t, productID); got != 77 { + t.Errorf("stock après UpdateProduct: got=%.2f want=77", got) + } +} + +// ── DeleteProduct ──────────────────────────────────────────────────────── + +func TestDeleteProduct_RemovesProductWithoutMedia(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "DelNoMedia", 3) + c, rec := productContext("admin", &bytes.Buffer{}, "application/x-www-form-urlencoded", productID) + handlers.DeleteProduct(c) + if rec.Code != http.StatusOK { + t.Fatalf("suppression produit sans média doit réussir: got=%d body=%s", rec.Code, rec.Body.String()) + } + var count int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM products WHERE id = ?`, productID).Scan(&count) + if count != 0 { + t.Errorf("le produit doit être supprimé: got=%d lignes restantes", count) + } +} + +// ── GetReservedQuantityInBaskets ───────────────────────────────────────── + +func TestGetReservedQuantityInBaskets_SumsNonRewardQuantities(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "ResQty", 20) + c1 := newTestClient(t, "resqty_c1") + c2 := newTestClient(t, "resqty_c2") + if _, err := testDB.AddToBasket(c1, productID, 3); err != nil { + t.Fatalf("AddToBasket c1: %v", err) + } + if _, err := testDB.AddToBasket(c2, productID, 5); err != nil { + t.Fatalf("AddToBasket c2: %v", err) + } + reserved, err := testDB.GetReservedQuantityInBaskets(productID) + if err != nil { + t.Fatalf("GetReservedQuantityInBaskets: %v", err) + } + if reserved != 8 { + t.Errorf("réservé total: got=%.2f want=8", reserved) + } +} + +// ── AddToBasket merge behavior ─────────────────────────────────────────── + +func TestAddToBasket_MergesIntoExistingNonRewardRow(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "merge_basket") + productID := newTestProduct(t, "MergeBasket", 20) + + if _, err := testDB.AddToBasket(username, productID, 2); err != nil { + t.Fatalf("AddToBasket #1: %v", err) + } + if _, err := testDB.AddToBasket(username, productID, 3); err != nil { + t.Fatalf("AddToBasket #2: %v", err) + } + + var count int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&count) + if count != 1 { + t.Fatalf("les deux ajouts doivent fusionner en une seule ligne: got=%d lignes", count) + } + var qty float64 + testDB.GDB.Raw(`SELECT quantity FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&qty) + if qty != 5 { + t.Errorf("quantité fusionnée: got=%.2f want=5", qty) + } +} + +// ── Checkout validation (db.CreateCommandWithAddress) ─────────────────── + +func TestCreateCommandWithAddress_RejectsEmptyBasket(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "checkout_empty") + _, err := testDB.CreateCommandWithAddress(username, "1 rue vide") + if err == nil { + t.Fatal("checkout avec panier vide doit échouer") + } +} + +func TestCreateCommandWithAddress_RejectsInvalidBasketItemData(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "checkout_invalid_item") + productID := newTestProduct(t, "CheckoutInvalidItem", 10) + // Insertion directe d'une ligne de panier avec quantité invalide, + // en contournant AddToBasket qui la rejetterait. + testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`, + username, productID, 0, 10, + ) + _, err := testDB.CreateCommandWithAddress(username, "1 rue invalide") + if err == nil { + t.Fatal("checkout avec donnée panier invalide (quantité=0) doit échouer") + } + testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username) +} + +func TestCreateCommandWithAddress_RejectsTotalPriceOverMax(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "checkout_overmax") + productID := newTestProduct(t, "CheckoutOverMax", 10) + testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward) VALUES (?, ?, ?, ?, false)`, + username, productID, 1, 100001, + ) + _, err := testDB.CreateCommandWithAddress(username, "1 rue trop cher") + if err == nil { + t.Fatal("checkout avec montant total > 100000 doit échouer") + } + testDB.GDB.Exec(`DELETE FROM baskets WHERE username = ?`, username) +} + +// ── DeleteCommandItem ───────────────────────────────────────────────────── + +func TestDeleteCommandItem_UpdatesCommandTotalPrix(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delitem_total") + productID := newTestProduct(t, "DelItemTotal", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20) + testDB.GDB.Exec(`UPDATE commandes SET total_prix = 20 WHERE id = ?`, cmdID) + + var itemID int + testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ?`, cmdID).Scan(&itemID) + + if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil { + t.Fatalf("DeleteCommandItem: %v", err) + } + + var totalPrix float64 + testDB.GDB.Raw(`SELECT total_prix FROM commandes WHERE id = ?`, cmdID).Scan(&totalPrix) + if totalPrix != 0 { + t.Errorf("total_prix après suppression du seul item: got=%.2f want=0", totalPrix) + } +} + +func TestDeleteCommandItem_UnknownItemReturnsError(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delitem_unknown") + productID := newTestProduct(t, "DelItemUnknown", 10) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 2, 20) + + if err := testDB.DeleteCommandItem(cmdID, 99999999); err == nil { + t.Fatal("suppression d'un item inconnu doit retourner une erreur") + } +} + +func TestDeleteCommandItem_UnknownCommandReturnsError(t *testing.T) { + cleanupStockTestData(t) + if err := testDB.DeleteCommandItem(99999999, 1); err == nil { + t.Fatal("suppression d'un item sur une commande inconnue doit retourner une erreur") + } +} diff --git a/backend/gestion/tests/stock_item_test.go b/backend/gestion/tests/stock_item_test.go new file mode 100644 index 00000000..9b1e0cd2 --- /dev/null +++ b/backend/gestion/tests/stock_item_test.go @@ -0,0 +1,116 @@ +package tests + +import ( + "sync" + "testing" +) + +func firstItemID(t *testing.T, commandID int) int { + t.Helper() + var itemID int + if err := testDB.GDB.Raw(`SELECT id FROM command_items WHERE command_id = ? LIMIT 1`, commandID).Scan(&itemID).Error; err != nil { + t.Fatalf("lecture item de la commande %d: %v", commandID, err) + } + return itemID +} + +// Supprimer un item d'une commande encore active doit restituer le stock de +// cet item. +func TestDeleteCommandItem_RestoresStockOnActiveOrder(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delitem_active") + productID := newTestProduct(t, "DelItemActive", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) + itemID := firstItemID(t, cmdID) + + if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil { + t.Fatalf("DeleteCommandItem: %v", err) + } + + if got := productStock(t, productID); got != 8 { + t.Errorf("stock après suppression d'item sur commande active (5 initial + 3 remboursés): got=%.2f want=8", got) + } + + var remaining int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM command_items WHERE id = ?`, itemID).Scan(&remaining) + if remaining != 0 { + t.Errorf("l'item doit être supprimé: got=%d lignes restantes", remaining) + } +} + +// Sur une commande déjà terminale (approuvée/livrée/annulée), le stock a +// déjà été traité par le chemin correspondant — supprimer un item a +// posteriori ne doit pas rembourser une seconde fois. +func TestDeleteCommandItem_DoesNotRestoreStockOnTerminalOrder(t *testing.T) { + for _, status := range []string{"approved", "livre", "cancelled"} { + t.Run(status, func(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delitem_"+status) + productID := newTestProduct(t, "DelItem"+status, 5) + cmdID := newTestCommandWithItem(t, username, status, "", productID, 3, 30) + itemID := firstItemID(t, cmdID) + + if err := testDB.DeleteCommandItem(cmdID, itemID); err != nil { + t.Fatalf("DeleteCommandItem: %v", err) + } + + if got := productStock(t, productID); got != 5 { + t.Errorf("stock ne doit pas bouger pour une commande %q: got=%.2f want=5", status, got) + } + }) + } +} + +// DeleteCommandItem verrouille désormais le statut de la commande (FOR +// UPDATE) avant de décider de rembourser, dans la même transaction — ce test +// vérifie qu'une suppression d'item et une annulation complète de la même +// commande, déclenchées en concurrence, ne remboursent le stock qu'une +// seule fois (peu importe laquelle des deux "gagne" la course). +func TestDeleteCommandItem_ConcurrentWithFullCancelDoesNotDoubleRefund(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "delitem_concurrent") + productID := newTestProduct(t, "DelItemConcurrent", 5) + cmdID := newTestCommandWithItem(t, username, "pending", "", productID, 3, 30) + itemID := firstItemID(t, cmdID) + + var wg sync.WaitGroup + start := make(chan struct{}) + wg.Add(2) + go func() { + defer wg.Done() + <-start + _ = testDB.DeleteCommandItem(cmdID, itemID) + }() + go func() { + defer wg.Done() + <-start + _, _ = testDB.CancelCommandAtomic(cmdID, username, "test", false) + }() + close(start) + wg.Wait() + + if got := productStock(t, productID); got != 8 { + t.Errorf("stock après suppression d'item + annulation complète concurrentes (5 initial + 3 remboursés une seule fois attendu): got=%.2f want=8", got) + } +} + +// ── SetProductStock (override direct admin) ───────────────────────────────── + +func TestSetProductStock_SetsExactValue(t *testing.T) { + cleanupStockTestData(t) + productID := newTestProduct(t, "SetStockDirect", 5) + + if err := testDB.SetProductStock(productID, 42); err != nil { + t.Fatalf("SetProductStock: %v", err) + } + if got := productStock(t, productID); got != 42 { + t.Errorf("stock après SetProductStock: got=%.2f want=42", got) + } +} + +func TestSetProductStock_RejectsUnknownProduct(t *testing.T) { + cleanupStockTestData(t) + if err := testDB.SetProductStock(-1, 10); err == nil { + t.Fatal("attendu une erreur pour un produit inexistant") + } +} diff --git a/backend/gestion/tests/stock_reward_same_product_test.go b/backend/gestion/tests/stock_reward_same_product_test.go new file mode 100644 index 00000000..91996908 --- /dev/null +++ b/backend/gestion/tests/stock_reward_same_product_test.go @@ -0,0 +1,137 @@ +package tests + +import "testing" + +// Scénario métier précis : un client réclame une récompense sur un produit, +// puis ajoute EN PLUS ce même produit à son panier en achat normal (quantité +// différente). AddToBasket filtre ses recherches d'item existant sur +// "is_reward = false" (voir db_basket.go), donc les deux lignes ne sont +// jamais fusionnées : elles restent deux lignes distinctes en base +// (product_id identique, is_reward différent). Le checkout doit décrémenter +// le stock de la SOMME des deux quantités, pas juste de l'une des deux. + +func insertNormalBasketRow(t *testing.T, username string, productID int, quantity, price float64) { + t.Helper() + if err := testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at) + VALUES (?, ?, ?, ?, false, CURRENT_TIMESTAMP)`, + username, productID, quantity, price, + ).Error; err != nil { + t.Fatalf("insertion panier normal: %v", err) + } +} + +func insertRewardBasketRow(t *testing.T, username string, productID int, quantity float64) { + t.Helper() + if err := testDB.GDB.Exec( + `INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at) + VALUES (?, ?, ?, 0, true, 'pool_0', CURRENT_TIMESTAMP)`, + username, productID, quantity, + ).Error; err != nil { + t.Fatalf("insertion panier récompense: %v", err) + } +} + +func TestCheckout_SameProductRewardAndNormalBothDecrementSeparately(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_same_product") + productID := newTestProduct(t, "RewardSameProduct", 20) + + // 1 unité offerte (récompense) + 5 unités achetées normalement, même produit. + insertRewardBasketRow(t, username, productID, 1) + insertNormalBasketRow(t, username, productID, 5, 50) + + var basketCount int64 + testDB.GDB.Raw(`SELECT COUNT(*) FROM baskets WHERE username = ? AND product_id = ?`, username, productID).Scan(&basketCount) + if basketCount != 2 { + t.Fatalf("précondition: deux lignes panier distinctes attendues (reward + normal), got=%d", basketCount) + } + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + + // 20 initial - 1 (récompense) - 5 (normal) = 14, pas 19 ni 15. + if got := productStock(t, productID); got != 14 { + t.Errorf("stock après checkout (récompense + normal sur le même produit): got=%.2f want=14", got) + } + + var items []struct { + Quantite float64 `gorm:"column:quantite"` + IsReward bool `gorm:"column:is_reward"` + } + if err := testDB.GDB.Raw( + `SELECT quantite, is_reward FROM command_items WHERE product_id = ? ORDER BY is_reward`, + productID, + ).Scan(&items).Error; err != nil { + t.Fatalf("lecture command_items: %v", err) + } + if len(items) != 2 { + t.Fatalf("deux command_items distincts attendus (reward + normal), got=%d", len(items)) + } + if items[0].IsReward || items[0].Quantite != 5 { + t.Errorf("item normal: got IsReward=%v Quantite=%.2f, want IsReward=false Quantite=5", items[0].IsReward, items[0].Quantite) + } + if !items[1].IsReward || items[1].Quantite != 1 { + t.Errorf("item récompense: got IsReward=%v Quantite=%.2f, want IsReward=true Quantite=1", items[1].IsReward, items[1].Quantite) + } +} + +// Si l'article payant (même produit) fait basculer le panier sous le stock +// disponible, tout le checkout doit échouer — y compris la part récompense, +// déjà vérifié pour des produits différents dans rewards_test.go, ici avec +// le MÊME produit sur les deux lignes. +func TestCheckout_SameProductRewardAndNormalRollsBackTogetherOnInsufficientStock(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_same_product_fail") + productID := newTestProduct(t, "RewardSameProductFail", 3) + + insertRewardBasketRow(t, username, productID, 1) + insertNormalBasketRow(t, username, productID, 5, 50) // 1 + 5 = 6 > stock disponible (3) + + if _, err := testDB.CreateCommandWithAddress(username, "1 rue de test"); err == nil { + t.Fatal("attendu un échec de checkout (1 + 5 = 6 unités demandées pour 3 en stock)") + } + + if got := productStock(t, productID); got != 3 { + t.Errorf("stock ne doit pas bouger si le total (reward+normal) dépasse le stock: got=%.2f want=3", got) + } +} + +// Une fois la commande passée (récompense + normal sur le même produit), +// l'annulation doit rembourser la somme des deux quantités, en une seule +// fois (la requête de remboursement agrège déjà par product_id sur +// l'ensemble des command_items — voir db_cancel_command.go). +func TestCancelCommandAtomic_RefundsBothRewardAndNormalLinesForSameProduct(t *testing.T) { + cleanupStockTestData(t) + username := newTestClient(t, "reward_same_product_cancel") + productID := newTestProduct(t, "RewardSameProductCancel", 20) + + insertRewardBasketRow(t, username, productID, 1) + insertNormalBasketRow(t, username, productID, 5, 50) + + cmd, err := testDB.CreateCommandWithAddress(username, "1 rue de test") + if err != nil { + t.Fatalf("CreateCommandWithAddress: %v", err) + } + if got := productStock(t, productID); got != 14 { + t.Fatalf("précondition stock post-checkout: got=%.2f want=14", got) + } + + if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err != nil { + t.Fatalf("CancelCommandAtomic: %v", err) + } + + // 14 + 1 (reward) + 5 (normal) = 20, retour au stock initial exact. + if got := productStock(t, productID); got != 20 { + t.Errorf("stock après annulation (remboursement des deux lignes du même produit): got=%.2f want=20", got) + } + + // Rejeu : ne doit rembourser qu'une fois, même avec deux lignes sur le même produit. + if _, err := testDB.CancelCommandAtomic(cmd.ID, username, "test", false); err == nil { + t.Fatal("le second appel sur une commande déjà annulée doit échouer, pas rembourser une seconde fois") + } + if got := productStock(t, productID); got != 20 { + t.Errorf("stock après double annulation (reward+normal même produit): got=%.2f want=20 (un seul remboursement)", got) + } +} diff --git a/backend/gestion/tests/utils_normalize_address_test.go b/backend/gestion/tests/utils_normalize_address_test.go new file mode 100644 index 00000000..cf6fe4fd --- /dev/null +++ b/backend/gestion/tests/utils_normalize_address_test.go @@ -0,0 +1,57 @@ +package tests + +import ( + "gestion/utils" + "strings" + "testing" +) + +// utils.NormalizeAddress supprime les accents et normalise les espaces, mais +// ne touche PAS à la casse — les appelants qui veulent une comparaison +// insensible à la casse doivent combiner avec strings.EqualFold ou +// strings.ToLower (voir services/adresses_correction.go et db/db_address.go). +// Ce fichier fixe ce contrat par des tests, pour éviter qu'un futur appelant +// suppose à tort que la casse est déjà gérée. + +func TestNormalizeAddress_RemovesAccents(t *testing.T) { + got := utils.NormalizeAddress("12 Rue Crébillon, Nantés") + want := "12 Rue Crebillon, Nantes" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +func TestNormalizeAddress_CollapsesMultipleAndTrailingSpaces(t *testing.T) { + got := utils.NormalizeAddress(" 12 Rue de la Paix ") + want := "12 Rue de la Paix" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} + +func TestNormalizeAddress_DoesNotChangeCase(t *testing.T) { + // Contrat volontaire : NormalizeAddress n'abaisse pas la casse. Un + // appelant qui veut une comparaison insensible à la casse doit le faire + // explicitement (strings.EqualFold côté appelant). + got := utils.NormalizeAddress("Rue De La Paix") + if got == strings.ToLower(got) { + t.Fatalf("ce test suppose une entrée avec des majuscules significatives, corrige le cas de test") + } + if got != "Rue De La Paix" { + t.Errorf("NormalizeAddress ne doit pas modifier la casse: got=%q", got) + } +} + +func TestNormalizeAddress_EmptyStringReturnsEmpty(t *testing.T) { + if got := utils.NormalizeAddress(""); got != "" { + t.Errorf("got=%q want=%q", got, "") + } +} + +func TestNormalizeAddress_CombinedAccentsAndSpaces(t *testing.T) { + got := utils.NormalizeAddress(" 20 Boulevard Général de Gaulle, Nantés ") + want := "20 Boulevard General de Gaulle, Nantes" + if got != want { + t.Errorf("got=%q want=%q", got, want) + } +} diff --git a/backend/gestion/utils/auth.go b/backend/gestion/utils/auth.go index a8e2ac07..6583cb43 100644 --- a/backend/gestion/utils/auth.go +++ b/backend/gestion/utils/auth.go @@ -32,29 +32,13 @@ func NormalizePhoneNumber(phone string) string { } func CheckRoleAdmin(c *gin.Context, role string) bool { - if role == "admin" { - return true - } - return false + return role == "admin" } func CheckRoleClient(c *gin.Context, role string) bool { - if role == "client" { - return true - } - return false + return role == "client" } func CheckRoleCabine(c *gin.Context, role string) bool { - if role == "cabine" { - return true - } - return false -} - -func CheckRoleLivreur(c *gin.Context, role string) bool { - if role == "livreur" { - return true - } - return false + return role == "cabine" } diff --git a/backend/gestion/utils/utils.go b/backend/gestion/utils/utils.go index 53635094..526b33ca 100644 --- a/backend/gestion/utils/utils.go +++ b/backend/gestion/utils/utils.go @@ -3,25 +3,16 @@ package utils import ( "crypto/rand" "fmt" - "gestion/db" - "gestion/models" "math/big" - "os" "path/filepath" + "strings" + "unicode" + + "golang.org/x/text/runes" + "golang.org/x/text/transform" + "golang.org/x/text/unicode/norm" ) -// -------------------------------------------- -// MEDIA -// -------------------------------------------- - -func GetMediaForProduct(productID int) ([]models.Media, error) { - return db.DB.GetMediaByProductID(productID) -} - -// -------------------------------------------- -// FILES NAMES -// -------------------------------------------- - func GenerateUniqueFileName(productName string, originalFileName string) string { n, _ := rand.Int(rand.Reader, big.NewInt(1000000)) randomString := fmt.Sprintf("%d", n.Int64()) @@ -29,6 +20,26 @@ func GenerateUniqueFileName(productName string, originalFileName string) string return fmt.Sprintf("%s_%s%s", productName, randomString, ext) } -func DeleteOldFile(filePath string) error { - return os.Remove(filePath) +// SanitizeFilePath valide qu'un chemin de fichier local reste bien dans le +// dossier uploads/ et ne contient pas de tentative de path traversal. +func SanitizeFilePath(path string) (string, error) { + cleaned := filepath.Clean(path) + + if strings.Contains(cleaned, "..") { + return "", fmt.Errorf("path traversal détecté") + } + + if !strings.HasPrefix(cleaned, "uploads/") && !strings.HasPrefix(cleaned, "uploads\\") { + return "", fmt.Errorf("chemin invalide") + } + + return cleaned, nil +} + +// NormalizeAddress supprime les accents et normalise les espaces, pour +// comparer deux adresses saisies différemment (casse/accents/espaces). +func NormalizeAddress(s string) string { + t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC) + result, _, _ := transform.String(t, s) + return strings.Join(strings.Fields(result), " ") } diff --git a/backend/gestion/workers/payment_checker.go b/backend/gestion/workers/payment_checker.go index f9504bc3..e1022774 100644 --- a/backend/gestion/workers/payment_checker.go +++ b/backend/gestion/workers/payment_checker.go @@ -7,16 +7,6 @@ import ( "time" ) -func StartPaymentChecker(database *db.Database, np *services.NowPaymentsClient, interval time.Duration) { - go func() { - for { - time.Sleep(interval) - checkPendingCryptoPayments(database, np) - } - }() - log.Printf("[CRON] payment checker démarré (toutes les %s)", interval) -} - // StartDynamicPaymentChecker démarre un checker qui recharge le client NowPayments à chaque tick // (permet de prendre en compte les changements de clé API sans redémarrer) func StartDynamicPaymentChecker(database *db.Database, clientFn func() *services.NowPaymentsClient, interval time.Duration) { diff --git a/frontend-admin/app.json b/frontend-admin/app.json index 28f89dd6..48252ce8 100644 --- a/frontend-admin/app.json +++ b/frontend-admin/app.json @@ -53,11 +53,14 @@ } }, "owner": "xor290", - "runtimeVersion": { - "policy": "appVersion" - }, + "runtimeVersion": "admin-1.0.1", "updates": { - "url": "https://u.expo.dev/fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440" + "url": "https://u.expo.dev/fcb7a0bc-5b2f-453d-ba16-9f97d9f0e440", + "codeSigningCertificate": "./certs/certificate.pem", + "codeSigningMetadata": { + "keyid": "main", + "alg": "rsa-v1_5-sha256" + } } } } diff --git a/frontend-admin/certs/certificate.pem b/frontend-admin/certs/certificate.pem new file mode 100644 index 00000000..a53e9f6b --- /dev/null +++ b/frontend-admin/certs/certificate.pem @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC9zCCAd+gAwIBAgIJOs/S1ceI7Zq6MA0GCSqGSIb3DQEBCwUAMCUxIzAhBgNV +BAMTGk1pbGlldSBOYW50YWlzIC8gVWJlciBTdHVwMB4XDTI2MDcxMTIwNDI1MloX +DTM2MDcxMTIwNDI1MlowJTEjMCEGA1UEAxMaTWlsaWV1IE5hbnRhaXMgLyBVYmVy +IFN0dXAwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQClkcj0h9gBuq9z +FVT1UwhBto2sTZglO3iwsgyPWx7I7twyY+kUU57rgHzI7YGieX599xM4oGjau8r7 +PjPK1160djTuRa9bWXnYBnotaU0Hp3rOicxbMygCGQoZtDqxRUMo4HxrBSYnZaVo +VYPqs/utSTA43El7SrzJddxBZK4WbJbfgdXDYrdeLz4Syrdx8DXBnCYmmmHhpQsE +orykYCUi7qd0CJi6kZGVOgR+Hq0B581DqnUA2H3iQWdk/0EZf6PN/gR0f9YlH3oN +N8tYLo7TOScSmUNJ5T2hFEuWuS/O6JKUI6a7MpIOv7XYxNDYWT/Ae9QNT8GcqVcW +39j2xSvvAgMBAAGjKjAoMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAKBggr +BgEFBQcDAzANBgkqhkiG9w0BAQsFAAOCAQEAj+SMnO7/IFajlg6Uo6aJotM8vdPp +4Dgi18DURZ0evUsxm4lLHWQ//6zF8jVaqPwsKA9IuPW+8O8gC8iwCY3YfjbaC5Ad +vPlvpzU6bvaA//utLVVUlfkk87vs5QotJkshoImJJoDPfO/Q1yv1qrMHXPnGyyzV +K0K3rYeXVYMDeJ9y2742D+MEg0Zse7xmNcde2z5aUuFlK7ORBs03FohD2U5zUqUg +jaia/wN4lIMCdJJmoPRUydbLJ8yVns9whFxXU1eGqaFf27jBdI/nPMVmO1YPsxnk +VvOiP6n1T+aZ7qaeOY9hsSmJ9FBeh3pOtRrdxmA8wBxD3zAORfLl89mjsw== +-----END CERTIFICATE----- diff --git a/frontend-admin/eas.json b/frontend-admin/eas.json index 3dfa22d4..aab6d181 100644 --- a/frontend-admin/eas.json +++ b/frontend-admin/eas.json @@ -23,7 +23,7 @@ }, "env": { "EXPO_PUBLIC_API_URL": "https://uber-demo.club", - "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club" + "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest" }, "channel": "pre-prod-admin" }, @@ -35,7 +35,7 @@ }, "env": { "EXPO_PUBLIC_API_URL": "https://mln-uber.club", - "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club" + "EXPO_PUBLIC_UPDATE_URL": "https://ota.uber-stup.club/api/manifest" }, "channel": "production-admin" } diff --git a/frontend-admin/package-lock.json b/frontend-admin/package-lock.json index 12738e27..c36ea046 100644 --- a/frontend-admin/package-lock.json +++ b/frontend-admin/package-lock.json @@ -2757,7 +2757,7 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "devOptional": true, + "dev": true, "dependencies": { "csstype": "^3.0.2" } @@ -3771,7 +3771,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true + "dev": true }, "node_modules/debug": { "version": "4.4.3", @@ -3966,29 +3966,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -7697,13 +7674,6 @@ ], "license": "MIT" }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "optional": true, - "peer": true - }, "node_modules/sax": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz", diff --git a/frontend-admin/src/api/api_admin.ts b/frontend-admin/src/api/api_admin.ts index 1bf09c94..3584518c 100644 --- a/frontend-admin/src/api/api_admin.ts +++ b/frontend-admin/src/api/api_admin.ts @@ -104,6 +104,36 @@ export interface ProductQuantityBreakdown { quantities: QuantityStat[]; } +export interface DailyProductItem { + product_id: number; + name: string; + quantity: number; + order_count: number; + revenue: number; +} +export interface DailyCategoryDetail { + category: string; + category_color: string; + total_quantity: number; + total_revenue: number; + products: DailyProductItem[]; +} +export interface DailyDetail { + date: string; + total_orders: number; + total_quantity: number; + total_revenue: number; + categories: DailyCategoryDetail[]; +} + +export type StatSection = + | "commandes" + | "revenus" + | "produits" + | "heures" + | "jours" + | "doses"; + export interface AdminStats { summary: StatsSummary; by_weekday: WeekdayStat[]; @@ -112,6 +142,13 @@ export interface AdminStats { by_hour: HourStat[]; top_products: ProductStat[]; by_quantity: ProductQuantityBreakdown[]; + daily_detail?: DailyDetail; + reset_at_commandes?: string; + reset_at_revenus?: string; + reset_at_produits?: string; + reset_at_heures?: string; + reset_at_jours?: string; + reset_at_doses?: string; } export const getAdminStats = async (): Promise => { @@ -119,6 +156,55 @@ export const getAdminStats = async (): Promise => { return data; }; +export const getAdminDailyDetail = async ( + date: string, +): Promise => { + const { data } = await apiClient.get(`${V2}/admin/protected/stats/daily`, { + params: { date }, + }); + return data; +}; + +export const resetAdminStats = async ( + section: StatSection, +): Promise<{ success: boolean; reset_at: string }> => { + const { data } = await apiClient.post( + `${V2}/admin/protected/stats/reset/${section}`, + ); + return data; +}; +// ============================================ +// STATISTIQUES MENSUELLES (jour par jour) +// ============================================ +export interface MonthlyDayStat { + day: string; // "2026-06-05" + label: string; // "05/06" + count: number; + revenue: number; + quantity: number; +} +export interface MonthlyStatsSummary { + total_orders: number; + total_revenue: number; + total_quantity: number; +} +export interface MonthlyStats { + month: string; // "2026-06" + summary: MonthlyStatsSummary; + by_day: MonthlyDayStat[]; +} + +export const getAdminStatsByMonth = async ( + month?: string, +): Promise => { + const { data } = await apiClient.get( + `${V2}/admin/protected/stats/monthly`, + { + params: month ? { month } : undefined, + }, + ); + return data; +}; export const getAllClients = async (): Promise => { const { data } = await apiClient.get(`${V2}/admin/protected/all/clients`); return data.clients || []; @@ -237,9 +323,17 @@ export const validateCommand = async (commandId: number) => { `${V2}/admin/protected/orders/${commandId}/force-validate`, { command_id: commandId }, ); - const validated = (data.validated ?? []) as { command_id: number; points_awarded: number }[]; - const points = validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0; - return { success: true, points_awarded: points, validated_count: data.validated_count ?? 0 }; + const validated = (data.validated ?? []) as { + command_id: number; + points_awarded: number; + }[]; + const points = + validated.find((v) => v.command_id === commandId)?.points_awarded ?? 0; + return { + success: true, + points_awarded: points, + validated_count: data.validated_count ?? 0, + }; }; export const proposeAddressChangeAdmin = async ( @@ -264,7 +358,6 @@ export const notifyClientToDescend = async (commandId: number) => { }; }; - // ============================================ export const getAvailableDeliveryPersons = async () => { @@ -299,6 +392,74 @@ export const getDeliveryPersonDetails = async (username: string) => { return data.deliveryman || data; }; +export const getLivreurRatings = async ( + username: string, +): Promise<{ + ratings: { + id: number; + order_id: number; + client_username: string; + rating: number; + comment: string; + created_at: string; + }[]; + average: number; + count: number; +}> => { + try { + const { data } = await apiClient.get( + `${V2}/admin/protected/delivery-persons/${username}/ratings`, + ); + return data; + } catch { + return { ratings: [], average: 0, count: 0 }; + } +}; + +export interface LoginHistoryEntry { + id: number; + username: string; + created_at: string; +} +export interface LoginHistoryWeek { + week: number; + entries: LoginHistoryEntry[]; +} + +export const getLivreurLoginHistory = async ( + username: string, + year?: number, + month?: number, +): Promise<{ + success: boolean; + year: number; + month: number; + weeks: LoginHistoryWeek[]; + count: number; +}> => { + try { + const { data } = await apiClient.get( + `${V2}/admin/protected/delivery-persons/${username}/login-history`, + { params: { year, month } }, + ); + return { + success: true, + year: data.year, + month: data.month, + weeks: data.weeks || [], + count: data.count || 0, + }; + } catch { + return { + success: false, + year: year ?? new Date().getFullYear(), + month: month ?? new Date().getMonth() + 1, + weeks: [], + count: 0, + }; + } +}; + const parseStatus = (status: any): "available" | "busy" | "offline" => { if (!status) return "offline"; if (status === "available" || status === "busy" || status === "offline") @@ -446,10 +607,11 @@ export const createProductAdmin = async (formData: FormData) => { `${V2}/admin/protected/products`, formData, { + headers: { "Content-Type": "multipart/form-data" }, timeout: 120000, }, ); - return { success: true, data: data.product, message: data.message }; + return { success: true, data: data.data, message: data.message }; }; export const updateProductAdmin = async ( @@ -544,6 +706,18 @@ export const setClientParrain = async ( return { success: false, error: e.response?.data?.error || "Erreur" }; } }; +export const getClientParrain = async ( + username: string, +): Promise<{ success: boolean; parrain?: string | null; error?: string }> => { + try { + const { data } = await apiClient.get( + `${V2}/admin/protected/client/${username}/parrain`, + ); + return { success: true, parrain: data.parrain ?? null }; + } catch (e: any) { + return { success: false, error: e.response?.data?.error || "Erreur" }; + } +}; export const creditClientReferral = async ( username: string, @@ -748,7 +922,7 @@ export const uploadProductMediaAdmin = async ( const { data } = await apiClient.post( `${V2}/admin/protected/products/${productId}/media`, fd, - { timeout: 120000 }, + { headers: { "Content-Type": "multipart/form-data" }, timeout: 120000 }, ); return { success: true, media: data.media, message: data.message }; }; @@ -841,6 +1015,7 @@ export interface Category { name: string; color: string; is_coming_soon: boolean; + position: number; created_at: string; } @@ -906,6 +1081,22 @@ export const deleteCategoryAdmin = async ( } }; +export const reorderCategoriesAdmin = async ( + ids: number[], +): Promise<{ success: boolean; error?: string }> => { + try { + await apiClient.put(`${V2}/admin/protected/categories/reorder`, { + ids, + }); + return { success: true }; + } catch (error: any) { + return { + success: false, + error: error.response?.data?.error || "Erreur", + }; + } +}; + // ============================================ // PARAMÈTRES GLOBAUX // ============================================ @@ -1050,6 +1241,18 @@ export interface AppSettings { delivery_mode: DeliveryModeConfig; shop_name: string; contact_telegram: string; + admin_color_primary: string; + admin_color_secondary: string; + admin_color_success: string; + admin_color_danger: string; + admin_color_warning: string; + client_color_primary: string; + client_color_secondary: string; + client_color_success: string; + client_color_danger: string; + client_color_warning: string; + client_title_gradient_from: string; + client_title_gradient_to: string; } export const getSettings = async (): Promise<{ diff --git a/frontend-admin/src/api/api_delivery.ts b/frontend-admin/src/api/api_delivery.ts index 427cd367..30aac8c1 100644 --- a/frontend-admin/src/api/api_delivery.ts +++ b/frontend-admin/src/api/api_delivery.ts @@ -379,6 +379,8 @@ export const getMyStats = async (): Promise<{ by_day?: StatPoint[]; by_week?: StatPoint[]; by_month?: StatPoint[]; + today_count?: number; + today_revenue?: number; error?: string; }> => { try { @@ -388,12 +390,39 @@ export const getMyStats = async (): Promise<{ by_day: data.by_day || [], by_week: data.by_week || [], by_month: data.by_month || [], + today_count: data.today_count || 0, + today_revenue: data.today_revenue || 0, }; } catch (error: any) { return { success: false, error: error.response?.data?.error || "Erreur réseau" }; } }; +export interface LivreurRating { + id: number; + order_id: number; + livreur_username: string; + client_username: string; + rating: number; + comment: string; + created_at: string; +} + +export const getMyRatings = async (): Promise<{ + success: boolean; + ratings: LivreurRating[]; + average: number; + count: number; + error?: string; +}> => { + try { + const { data } = await apiClient.get(`${API}/ratings`); + return { success: true, ratings: data.ratings || [], average: data.average || 0, count: data.count || 0 }; + } catch (e: any) { + return { success: false, ratings: [], average: 0, count: 0, error: e?.response?.data?.error || "Erreur" }; + } +}; + export const reportDeliveryIssue = async ( deliveryId: number, issueType: IssueType, diff --git a/frontend-admin/src/api/tomtom.ts b/frontend-admin/src/api/tomtom.ts index 6f04394b..2a3bb8fc 100644 --- a/frontend-admin/src/api/tomtom.ts +++ b/frontend-admin/src/api/tomtom.ts @@ -70,6 +70,7 @@ export const maneuverIcons: Record = { DEFAULT: "arrow-up", }; +// ---- Helpers ---- function formatDistance(meters: number): string { if (meters < 1000) return `${Math.round(meters)} m`; return `${(meters / 1000).toFixed(1)} km`; diff --git a/frontend-admin/src/api/types.ts b/frontend-admin/src/api/types.ts index 59d4539e..e93cc343 100644 --- a/frontend-admin/src/api/types.ts +++ b/frontend-admin/src/api/types.ts @@ -1,166 +1,176 @@ // Shared types for admin panel export interface AdminUser { - id: number; - username: string; - role: string; + id: number; + username: string; + role: string; } export interface AuthResponse { - success: boolean; - message?: string; - access_token?: string; - token_type?: string; - expires_in?: number; - user?: AdminUser; + success: boolean; + message?: string; + access_token?: string; + token_type?: string; + expires_in?: number; + user?: AdminUser; } export interface ClientResponse { - id: number; - username: string; - nom: string; - prenom: string; - telephone: string; - adresse?: string; - role?: string; - created_at?: string; - updated_at?: string; - command: number; - points_extra: Record; - amende: number; - cancellations_count: number; - last_penalty_reason?: string; - referral_balance?: number; + id: number; + username: string; + nom: string; + prenom: string; + telephone: string; + adresse?: string; + role?: string; + created_at?: string; + updated_at?: string; + command: number; + points_extra: Record; + amende: number; + cancellations_count: number; + last_penalty_reason?: string; + referral_balance?: number; } export interface CommandResponse { - id: number; - client_order_number?: number; - username: string; - status: string; - adresse: string; - total_prix: number; - livreur_assign?: string | null; - proposed_address?: string | null; - address_proposal_status?: string; - referral_used?: number; - cancel_reason?: string; - created_at: string; - updated_at: string; + id: number; + client_order_number?: number; + username: string; + status: string; + adresse: string; + total_prix: number; + livreur_assign?: string | null; + proposed_address?: string | null; + address_proposal_status?: string; + referral_used?: number; + cancel_reason?: string; + created_at: string; + updated_at: string; } export interface OrderItem { - id: number; - command_id: number; - product_id: number; - product_name: string; - quantity: number; - price: number; - status: string; - created_at: string; + id: number; + command_id: number; + product_id: number; + product_name: string; + quantity: number; + price: number; + status: string; + created_at: string; } export interface Alert { - id: number; - username: string; - status: string; - message: string; - created_at: string; - updated_at: string; + id: number; + username: string; + status: string; + message: string; + created_at: string; + updated_at: string; } export interface DeliveryPerson { - id: number; - username: string; - status: 'available' | 'busy' | 'offline'; - location: { - latitude: number; - longitude: number; - last_update: string; - is_recent: boolean; - }; - stats: { - total_deliveries: number; - completed_today: number; - queue_size: number; - current_command: number | null; - }; + id: number; + username: string; + status: "available" | "busy" | "offline"; + location: { + latitude: number; + longitude: number; + last_update: string; + is_recent: boolean; + }; + stats: { + total_deliveries: number; + completed_today: number; + queue_size: number; + current_command: number | null; + }; } export interface DeliveryPersonsStats { - total: number; - available: number; - busy: number; - offline: number; - active_deliveries: number; + total: number; + available: number; + busy: number; + offline: number; + active_deliveries: number; } export interface Product { - id: number; - name: string; - description?: string; - category: string; - stock: number; - unit: string; - prices?: Array<{ id?: number; quantity: number; price: number; active_price?: boolean }>; - media?: Array<{ url: string; type: string; id?: number; created_at?: string }>; - coming_soon?: boolean; - created_at?: string; - updated_at?: string; + id: number; + name: string; + description?: string; + category: string; + stock: number; + unit: string; + prices?: Array<{ + id?: number; + quantity: number; + price: number; + active_price?: boolean; + }>; + media?: Array<{ + url: string; + type: string; + id?: number; + created_at?: string; + }>; + coming_soon?: boolean; + created_at?: string; + updated_at?: string; } export interface DeliveryStatus { - status: 'available' | 'busy' | 'offline'; - current_command?: number; - last_update?: number; + status: "available" | "busy" | "offline"; + current_command?: number; + last_update?: number; } export interface QueueInfo { - queue_size: number; - commands: any[]; + queue_size: number; + commands: any[]; } export interface DeliveryItemProduct { - produit: string; - quantite: number; - prix: number; + produit: string; + quantite: number; + prix: number; } export interface DeliveryItem { - id: number; - status: string; - adresse: string; - total_prix: number; - referral_used?: number; - created_at: string; - updated_at: string; - eta?: string; - items?: DeliveryItemProduct[]; - items_count?: number; + id: number; + status: string; + adresse: string; + total_prix: number; + referral_used?: number; + created_at: string; + updated_at: string; + eta?: string; + items?: DeliveryItemProduct[]; + items_count?: number; } export interface ClientInfo { - username: string; - nom?: string; - prenom?: string; - telephone?: string; + username: string; + nom?: string; + prenom?: string; + telephone?: string; } export interface DeliveryDetails { - delivery: DeliveryItem; - client_info: ClientInfo; + delivery: DeliveryItem; + client_info: ClientInfo; } export interface PenaltyInfo { - client_username: string; - current_amende: number; - cancellations_count: number; - next_penalty: number; + client_username: string; + current_amende: number; + cancellations_count: number; + next_penalty: number; } export interface MapLinks { - google_maps: string; - waze: string; - apple_maps: string; - openstreetmap: string; + google_maps: string; + waze: string; + apple_maps: string; + openstreetmap: string; } diff --git a/frontend-admin/src/auth/tokenStorage.ts b/frontend-admin/src/auth/tokenStorage.ts index e7f3c610..a8e35f81 100644 --- a/frontend-admin/src/auth/tokenStorage.ts +++ b/frontend-admin/src/auth/tokenStorage.ts @@ -1,35 +1,30 @@ -import AsyncStorage from "@react-native-async-storage/async-storage"; +import AsyncStorage from '@react-native-async-storage/async-storage'; -const TOKEN_KEY = "token"; -const ADMIN_TOKEN_KEY = "admin_token"; -const USERNAME_KEY = "username"; -const ADMIN_USERNAME_KEY = "admin_username"; -const ROLE_KEY = "user_role"; +const TOKEN_KEY = 'token'; +const ADMIN_TOKEN_KEY = 'admin_token'; +const USERNAME_KEY = 'username'; +const ADMIN_USERNAME_KEY = 'admin_username'; +const ROLE_KEY = 'user_role'; -// Token Client +// Client token export const getToken = () => AsyncStorage.getItem(TOKEN_KEY); -export const setToken = (token: string) => - AsyncStorage.setItem(TOKEN_KEY, token); +export const setToken = (token: string) => AsyncStorage.setItem(TOKEN_KEY, token); export const removeToken = () => AsyncStorage.removeItem(TOKEN_KEY); // Admin/Cabine/Livreur token export const getAdminToken = () => AsyncStorage.getItem(ADMIN_TOKEN_KEY); -export const setAdminToken = (token: string) => - AsyncStorage.setItem(ADMIN_TOKEN_KEY, token); +export const setAdminToken = (token: string) => AsyncStorage.setItem(ADMIN_TOKEN_KEY, token); export const removeAdminToken = () => AsyncStorage.removeItem(ADMIN_TOKEN_KEY); // Username export const getUsername = () => AsyncStorage.getItem(USERNAME_KEY); -export const setUsername = (username: string) => - AsyncStorage.setItem(USERNAME_KEY, username); +export const setUsername = (username: string) => AsyncStorage.setItem(USERNAME_KEY, username); export const removeUsername = () => AsyncStorage.removeItem(USERNAME_KEY); // Admin username export const getAdminUsername = () => AsyncStorage.getItem(ADMIN_USERNAME_KEY); -export const setAdminUsername = (username: string) => - AsyncStorage.setItem(ADMIN_USERNAME_KEY, username); -export const removeAdminUsername = () => - AsyncStorage.removeItem(ADMIN_USERNAME_KEY); +export const setAdminUsername = (username: string) => AsyncStorage.setItem(ADMIN_USERNAME_KEY, username); +export const removeAdminUsername = () => AsyncStorage.removeItem(ADMIN_USERNAME_KEY); // Role export const getRole = () => AsyncStorage.getItem(ROLE_KEY); @@ -38,11 +33,11 @@ export const removeRole = () => AsyncStorage.removeItem(ROLE_KEY); // Clear all auth data export const clearAllAuth = async () => { - await AsyncStorage.multiRemove([ - TOKEN_KEY, - ADMIN_TOKEN_KEY, - USERNAME_KEY, - ADMIN_USERNAME_KEY, - ROLE_KEY, - ]); + await AsyncStorage.multiRemove([ + TOKEN_KEY, + ADMIN_TOKEN_KEY, + USERNAME_KEY, + ADMIN_USERNAME_KEY, + ROLE_KEY, + ]); }; diff --git a/frontend-admin/src/context/ThemeContext.tsx b/frontend-admin/src/context/ThemeContext.tsx index 18db59fe..aceae804 100644 --- a/frontend-admin/src/context/ThemeContext.tsx +++ b/frontend-admin/src/context/ThemeContext.tsx @@ -1,6 +1,8 @@ -import React, { createContext, useContext, useState, useEffect, type ReactNode } from "react"; +import React, { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react"; +import { AppState } from "react-native"; import AsyncStorage from "@react-native-async-storage/async-storage"; import { darkColors, lightColors, type Colors } from "../theme/colors"; +import { getSettings } from "../api/api_admin"; type ThemeMode = "dark" | "light"; @@ -9,19 +11,53 @@ interface ThemeContextType { mode: ThemeMode; toggleTheme: () => void; isDark: boolean; + refreshColors: () => Promise; } const STORAGE_KEY = "@theme_mode"; +const COLORS_CACHE_KEY = "@admin_colors_cache"; const ThemeContext = createContext(undefined); export function ThemeProvider({ children }: { children: ReactNode }) { const [mode, setMode] = useState("dark"); + const [colorOverrides, setColorOverrides] = useState>({}); + + const fetchAdminColors = useCallback(async () => { + try { + const result = await getSettings(); + if (result.success && result.settings) { + const s = result.settings; + const overrides: Partial = { + ...(s.admin_color_primary && { accent: s.admin_color_primary }), + ...(s.admin_color_secondary && { secondary: s.admin_color_secondary }), + ...(s.admin_color_success && { success: s.admin_color_success }), + ...(s.admin_color_danger && { danger: s.admin_color_danger }), + ...(s.admin_color_warning && { warning: s.admin_color_warning }), + }; + setColorOverrides(overrides); + AsyncStorage.setItem(COLORS_CACHE_KEY, JSON.stringify(overrides)).catch(() => {}); + } + } catch {} + }, []); useEffect(() => { AsyncStorage.getItem(STORAGE_KEY).then((val) => { if (val === "light" || val === "dark") setMode(val); }); - }, []); + AsyncStorage.getItem(COLORS_CACHE_KEY).then((val) => { + if (val) { + try { setColorOverrides(JSON.parse(val)); } catch {} + } + }); + fetchAdminColors(); + }, [fetchAdminColors]); + + useEffect(() => { + const sub = AppState.addEventListener("change", (state) => { + if (state === "active") fetchAdminColors(); + }); + return () => sub.remove(); + }, [fetchAdminColors]); const toggleTheme = () => { const next = mode === "dark" ? "light" : "dark"; @@ -29,11 +65,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) { AsyncStorage.setItem(STORAGE_KEY, next); }; + const baseColors = mode === "dark" ? darkColors : lightColors; + const colors: Colors = { ...baseColors, ...colorOverrides }; + const value: ThemeContextType = { - colors: mode === "dark" ? darkColors : lightColors, + colors, mode, toggleTheme, isDark: mode === "dark", + refreshColors: fetchAdminColors, }; return ( diff --git a/frontend-admin/src/navigation/AdminNavigator.tsx b/frontend-admin/src/navigation/AdminNavigator.tsx index 52d81b3d..d1d087d6 100644 --- a/frontend-admin/src/navigation/AdminNavigator.tsx +++ b/frontend-admin/src/navigation/AdminNavigator.tsx @@ -93,7 +93,7 @@ function AdminTabs() { <> ( ), tabBarStyle: { - backgroundColor: colors.bgSecondary, + backgroundColor: isDark ? colors.secondary : "#ffffff", borderTopColor: colors.border, borderTopWidth: 1, }, @@ -339,12 +339,12 @@ function AdminTabs() { } export default function AdminNavigator() { - const { colors } = useTheme(); + const { colors, isDark } = useTheme(); return ( diff --git a/frontend-admin/src/navigation/DeliveryNavigator.tsx b/frontend-admin/src/navigation/DeliveryNavigator.tsx index 8086a168..a7697cc7 100644 --- a/frontend-admin/src/navigation/DeliveryNavigator.tsx +++ b/frontend-admin/src/navigation/DeliveryNavigator.tsx @@ -23,6 +23,7 @@ import type { DeliveryTabParamList } from "./types"; import DashboardScreen from "../screens/delivery/DashboardScreen"; import StatsScreen from "../screens/delivery/StatsScreen"; import AlertsScreen from "../screens/delivery/AlertsScreen"; +import RatingsScreen from "../screens/delivery/RatingsScreen"; const Tab = createBottomTabNavigator(); @@ -201,6 +202,20 @@ export default function DeliveryNavigator() { ), }} /> + ( + + ), + }} + /> {/* Modal notifications */} diff --git a/frontend-admin/src/navigation/types.ts b/frontend-admin/src/navigation/types.ts index 3a3b5b56..b6424541 100644 --- a/frontend-admin/src/navigation/types.ts +++ b/frontend-admin/src/navigation/types.ts @@ -36,4 +36,5 @@ export type DeliveryTabParamList = { Dashboard: undefined; Stats: undefined; Alerts: undefined; + Ratings: undefined; }; diff --git a/frontend-admin/src/screens/admin/CategoriesScreen.tsx b/frontend-admin/src/screens/admin/CategoriesScreen.tsx index 5104a39a..1f930730 100644 --- a/frontend-admin/src/screens/admin/CategoriesScreen.tsx +++ b/frontend-admin/src/screens/admin/CategoriesScreen.tsx @@ -20,6 +20,7 @@ import { createCategoryAdmin, updateCategoryAdmin, deleteCategoryAdmin, + reorderCategoriesAdmin, } from "../../api/api_admin"; import type { Category } from "../../api/api_admin"; import LoadingSpinner from "../../components/ui/LoadingSpinner"; @@ -47,6 +48,7 @@ export default function CategoriesScreen() { const [hexInput, setHexInput] = useState("#7c3aed"); const [isComingSoon, setIsComingSoon] = useState(false); const [saving, setSaving] = useState(false); + const [reordering, setReordering] = useState(false); const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert(); const load = useCallback(async () => { @@ -144,6 +146,17 @@ export default function CategoriesScreen() { ); }; + const handleMove = async (index: number, direction: "up" | "down") => { + const newList = [...categories]; + const swapIndex = direction === "up" ? index - 1 : index + 1; + if (swapIndex < 0 || swapIndex >= newList.length) return; + [newList[index], newList[swapIndex]] = [newList[swapIndex], newList[index]]; + setCategories(newList); + setReordering(true); + await reorderCategoriesAdmin(newList.map((c) => c.id)); + setReordering(false); + }; + const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: colors.bgPrimary }, header: { @@ -189,6 +202,8 @@ export default function CategoriesScreen() { paddingVertical: 2, }, comingSoonBadgeText: { fontSize: fontSize.xs, color: colors.warning, fontWeight: "600" }, + orderBtns: { flexDirection: "column", alignItems: "center", marginRight: spacing.s }, + orderBtn: { padding: 2 }, actions: { flexDirection: "row", gap: spacing.s }, actionBtn: { padding: 8 }, overlay: { @@ -297,9 +312,33 @@ export default function CategoriesScreen() { ListEmptyComponent={ Aucune catégorie. Créez-en une ! } - renderItem={({ item }) => ( + renderItem={({ item, index }) => ( + + handleMove(index, "up")} + disabled={index === 0 || reordering} + style={styles.orderBtn} + > + + + handleMove(index, "down")} + disabled={index === categories.length - 1 || reordering} + style={styles.orderBtn} + > + + + (null); const [routeLoading, setRouteLoading] = useState(false); + // Avis livreur + const [ratingsModal, setRatingsModal] = useState<{ + username: string; + ratings: { id: number; order_id: number; client_username: string; rating: number; comment: string; created_at: string }[]; + average: number; + count: number; + } | null>(null); + const [ratingsLoading, setRatingsLoading] = useState(false); + + const openRatings = async (username: string) => { + setRatingsLoading(true); + const data = await getLivreurRatings(username); + setRatingsModal({ username, ...data }); + setRatingsLoading(false); + }; + + // Historique de connexion livreur + const [loginHistoryModal, setLoginHistoryModal] = useState<{ + username: string; + year: number; + month: number; + weeks: LoginHistoryWeek[]; + } | null>(null); + const [loginHistoryLoading, setLoginHistoryLoading] = useState(false); + const loginHistoryRequestRef = useRef(0); + + const fetchLoginHistory = async ( + username: string, + year: number, + month: number, + ) => { + const requestId = ++loginHistoryRequestRef.current; + setLoginHistoryLoading(true); + const res = await getLivreurLoginHistory(username, year, month); + if (requestId !== loginHistoryRequestRef.current) return; + setLoginHistoryModal({ + username, + year: res.year, + month: res.month, + weeks: res.weeks, + }); + setLoginHistoryLoading(false); + }; + + const openLoginHistory = (username: string) => { + const now = new Date(); + fetchLoginHistory(username, now.getFullYear(), now.getMonth() + 1); + }; + + const changeLoginHistoryMonth = (delta: number) => { + if (!loginHistoryModal || loginHistoryLoading) return; + let year = loginHistoryModal.year; + let month = loginHistoryModal.month + delta; + if (month < 1) { + month = 12; + year -= 1; + } else if (month > 12) { + month = 1; + year += 1; + } + const now = new Date(); + if ( + year > now.getFullYear() || + (year === now.getFullYear() && month > now.getMonth() + 1) + ) { + return; + } + fetchLoginHistory(loginHistoryModal.username, year, month); + }; + const loadData = useCallback(async () => { try { const result = await getAllDeliveryPersonsWithDetails(); @@ -338,6 +413,66 @@ export default function DeliveryScreen() { fontWeight: "500", }, + ratingsBtn: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.s, + marginTop: spacing.m, + paddingVertical: spacing.s, + paddingHorizontal: spacing.m, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: "#f59e0b", + }, + ratingsBtnText: { + color: "#f59e0b", + fontSize: fontSize.sm, + fontWeight: "600", + }, + + historyBtn: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + gap: spacing.s, + marginTop: spacing.m, + paddingVertical: spacing.s, + paddingHorizontal: spacing.m, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: colors.accent, + }, + historyBtnText: { + color: colors.accent, + fontSize: fontSize.sm, + fontWeight: "600", + }, + + historyWeekLabel: { + color: colors.textMuted, + fontSize: fontSize.xs, + fontWeight: "700", + textTransform: "uppercase", + marginBottom: spacing.xs, + marginTop: spacing.m, + }, + historyEntryRow: { + flexDirection: "row", + justifyContent: "space-between", + paddingVertical: spacing.xs, + borderTopWidth: 1, + borderTopColor: colors.borderLight, + }, + historyEntryDate: { + color: colors.textPrimary, + fontSize: fontSize.sm, + }, + historyEntryTime: { + color: colors.textMuted, + fontSize: fontSize.sm, + }, + trackBtn: { flexDirection: "row", alignItems: "center", @@ -356,6 +491,80 @@ export default function DeliveryScreen() { fontWeight: "600", }, + ratingsOverlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.7)", + justifyContent: "flex-end", + }, + ratingsSheet: { + backgroundColor: colors.bgCard, + borderTopLeftRadius: borderRadius.xl, + borderTopRightRadius: borderRadius.xl, + padding: spacing.l, + maxHeight: "80%", + }, + ratingsHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: spacing.s, + }, + ratingsTitle: { + color: colors.textWhite, + fontSize: fontSize.lg, + fontWeight: "700", + }, + ratingsAvg: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + marginBottom: spacing.l, + }, + ratingsAvgText: { + color: "#f59e0b", + fontSize: fontSize.md, + fontWeight: "700", + }, + ratingsCount: { + color: colors.textMuted, + fontSize: fontSize.sm, + }, + ratingItem: { + borderTopWidth: 1, + borderTopColor: colors.borderLight, + paddingVertical: spacing.m, + }, + ratingItemHeader: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + marginBottom: spacing.xs, + }, + ratingItemClient: { + color: colors.textPrimary, + fontSize: fontSize.sm, + fontWeight: "600", + }, + ratingItemDate: { + color: colors.textMuted, + fontSize: fontSize.xs, + }, + ratingStarsRow: { + flexDirection: "row", + gap: 2, + marginBottom: spacing.xs, + }, + ratingItemComment: { + color: colors.textSecondary, + fontSize: fontSize.sm, + fontStyle: "italic", + }, + ratingsEmpty: { + color: colors.textMuted, + textAlign: "center", + paddingVertical: spacing.xl, + }, + empty: { color: colors.textMuted, textAlign: "center", @@ -523,6 +732,30 @@ export default function DeliveryScreen() { )} + openRatings(item.username)} + activeOpacity={0.7} + > + + Voir les avis + + + openLoginHistory(item.username)} + activeOpacity={0.7} + > + + + Historique de connexion + + + {hasGPS && ( Aucun livreur } /> + + {/* ── Modal avis livreur ── */} + setRatingsModal(null)} + > + setRatingsModal(null)}> + {}}> + + + + Avis — {ratingsModal?.username} + + setRatingsModal(null)}> + + + + {ratingsLoading ? ( + Chargement... + ) : ratingsModal && ratingsModal.count > 0 ? ( + <> + + {[1,2,3,4,5].map((s) => ( + + ))} + + {ratingsModal.average.toFixed(1)} + + + ({ratingsModal.count} avis) + + + + {ratingsModal.ratings.map((r) => ( + + + {r.client_username} + + {new Date(r.created_at).toLocaleDateString("fr-FR")} + + + + {[1,2,3,4,5].map((s) => ( + + ))} + + {r.comment !== "" && ( + "{r.comment}" + )} + + ))} + + + ) : ( + Aucun avis pour ce livreur + )} + + + + + + {/* ── Modal historique de connexion livreur ── */} + setLoginHistoryModal(null)} + > + setLoginHistoryModal(null)} + > + {}}> + + + + Connexions — {loginHistoryModal?.username} + + setLoginHistoryModal(null)} + > + + + + + {(() => { + const now = new Date(); + const isCurrentMonth = + !!loginHistoryModal && + loginHistoryModal.year === + now.getFullYear() && + loginHistoryModal.month === + now.getMonth() + 1; + const canGoBack = !loginHistoryLoading; + const canGoForward = + !loginHistoryLoading && !isCurrentMonth; + return ( + + + changeLoginHistoryMonth(-1) + } + hitSlop={8} + > + + + + {loginHistoryModal && + new Date( + loginHistoryModal.year, + loginHistoryModal.month - + 1, + 1, + ) + .toLocaleDateString( + "fr-FR", + { + month: "long", + year: "numeric", + }, + ) + .replace(/^./, (c) => + c.toUpperCase(), + )} + + + changeLoginHistoryMonth(1) + } + hitSlop={8} + > + + + + ); + })()} + + {loginHistoryLoading ? ( + + Chargement... + + ) : loginHistoryModal && + loginHistoryModal.weeks.length > 0 ? ( + + {loginHistoryModal.weeks.map((week) => ( + + + Semaine {week.week} + + {week.entries.map((entry) => ( + + + {new Date( + entry.created_at, + ).toLocaleDateString( + "fr-FR", + { + weekday: "short", + day: "2-digit", + month: "2-digit", + }, + )} + + + {new Date( + entry.created_at, + ).toLocaleTimeString( + "fr-FR", + { + hour: "2-digit", + minute: "2-digit", + }, + )} + + + ))} + + ))} + + ) : ( + + Aucune connexion ce mois-ci + + )} + + + + ); } diff --git a/frontend-admin/src/screens/admin/OrdersScreen.tsx b/frontend-admin/src/screens/admin/OrdersScreen.tsx index e375e310..a4dd9156 100644 --- a/frontend-admin/src/screens/admin/OrdersScreen.tsx +++ b/frontend-admin/src/screens/admin/OrdersScreen.tsx @@ -782,7 +782,7 @@ export default function OrdersScreen() { setOpenMenuId(null); handleForceValidate(item.id); }, - condition: !isDone && item.status !== "livre", + condition: !isDone, }, { label: "Supprimer", diff --git a/frontend-admin/src/screens/admin/ProductsScreen.tsx b/frontend-admin/src/screens/admin/ProductsScreen.tsx index 4b8c8deb..5104cfdd 100644 --- a/frontend-admin/src/screens/admin/ProductsScreen.tsx +++ b/frontend-admin/src/screens/admin/ProductsScreen.tsx @@ -62,12 +62,12 @@ interface PendingMedia { // Les catégories sont chargées dynamiquement depuis l'API const UNITS = [ - { value: "u", label: "u" }, - { value: "kg", label: "kg" }, - { value: "g", label: "g" }, + { value: "u", label: "u" }, + { value: "kg", label: "kg" }, + { value: "g", label: "g" }, { value: "bag", label: "bag" }, - { value: "l", label: "l" }, - { value: "cl", label: "cl" }, + { value: "l", label: "l" }, + { value: "cl", label: "cl" }, { value: "pcs", label: "pcs" }, ]; @@ -247,7 +247,11 @@ export default function ProductsScreen() { } const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: type === "image" ? ["images"] : ["videos"], + mediaTypes: + type === "image" + ? ImagePicker.MediaTypeOptions.Images + : ImagePicker.MediaTypeOptions.Videos, + quality: 0.8, allowsMultipleSelection: true, }); @@ -354,10 +358,7 @@ export default function ProductsScreen() { prices.forEach((p, i) => { fd.append(`prices[${i}][quantity]`, String(p.quantity)); fd.append(`prices[${i}][price]`, String(p.price)); - fd.append( - `prices[${i}][active_price]`, - p.active_price ? "true" : "false", - ); + fd.append(`prices[${i}][active_price]`, p.active_price ? "true" : "false"); }); // Attacher les médias en attente @@ -387,18 +388,13 @@ export default function ProductsScreen() { m.mediaType, ); } catch (uploadErr: any) { - const msg = - uploadErr?.response?.data?.error || - uploadErr?.message || - `Erreur upload ${m.mediaType}`; + const msg = uploadErr?.response?.data?.error || uploadErr?.message || `Erreur upload ${m.mediaType}`; uploadErrors.push(msg); } } setUploadingMedia(false); if (uploadErrors.length > 0) { - setFormError( - `Erreur upload média: ${uploadErrors.join(", ")}`, - ); + setFormError(`Erreur upload média: ${uploadErrors.join(", ")}`); await loadData(); return; } @@ -649,14 +645,8 @@ export default function ProductsScreen() { borderColor: "#22c55e", backgroundColor: "#22c55e20", }, - comingSoonBtnText: { - color: colors.textMuted, - fontSize: fontSize.sm, - }, - comingSoonBtnTextActive: { - color: "#22c55e", - fontWeight: "700", - }, + comingSoonBtnText: { color: colors.textMuted, fontSize: fontSize.sm }, + comingSoonBtnTextActive: { color: "#22c55e", fontWeight: "700" }, // Prices sectionHeader: { @@ -818,10 +808,7 @@ export default function ProductsScreen() { {item.name} c.name === item.category) - ?.color || colors.accent - } + color={categories.find(c => c.name === item.category)?.color || colors.accent} /> @@ -856,18 +843,9 @@ export default function ProductsScreen() { )} )} - - Stock: {item.stock} {item.unit || "u"} - + Stock: {item.stock} {item.unit || "u"} {item.prices && item.prices.length > 0 && ( - + {item.prices.map((p, i) => ( - {p.quantity} - {item.unit || "u"} = {p.price}€ + {p.quantity}{item.unit || "u"} = {p.price}€ {i < item.prices!.length - 1 ? " |" : ""} ))} @@ -1047,8 +1024,7 @@ export default function ProductsScreen() { setForm((f) => { @@ -1056,24 +1032,16 @@ export default function ProductsScreen() { return { ...f, comingSoon: next, - prices: f.prices.map((p) => ({ - ...p, - active: !next, - })), + prices: f.prices.map((p) => ({ ...p, active: !next })), }; }) } > - - {form.comingSoon - ? "À venir (activé)" - : "Marquer comme «À venir»"} + + {form.comingSoon ? "À venir (activé)" : "Marquer comme «À venir»"} @@ -1172,18 +1140,9 @@ export default function ProductsScreen() { onPress={() => togglePriceActive(idx)} > {form.prices.length > 1 && ( diff --git a/frontend-admin/src/screens/admin/SettingsScreen.tsx b/frontend-admin/src/screens/admin/SettingsScreen.tsx index b60e098c..bca1526a 100644 --- a/frontend-admin/src/screens/admin/SettingsScreen.tsx +++ b/frontend-admin/src/screens/admin/SettingsScreen.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from "react"; +import { useFocusEffect } from "@react-navigation/native"; import { View, Text, @@ -1155,9 +1156,25 @@ function CentralRewardSection({ ); } +// Palette violette d'origine de l'application (thème par défaut historique) +const ORIGINAL_THEME_COLORS = { + admin_color_primary: "#7c3aed", + admin_color_secondary: "#000000", + admin_color_success: "#4ade80", + admin_color_danger: "#ef4444", + admin_color_warning: "#f59e0b", + client_color_primary: "#7c3aed", + client_color_secondary: "#000000", + client_color_success: "#4ade80", + client_color_danger: "#ef4444", + client_color_warning: "#f59e0b", + client_title_gradient_from: "#a78bfa", + client_title_gradient_to: "#22d3ee", +} as const; + export default function SettingsScreen() { - const { colors } = useTheme(); - const { alert, showError, showSuccess, hideAlert } = useAlert(); + const { colors, refreshColors } = useTheme(); + const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert(); const navigation = useNavigation(); const { width: screenWidth } = useWindowDimensions(); const inputMd = screenWidth < 380 ? 64 : 80; @@ -1194,6 +1211,18 @@ export default function SettingsScreen() { shop_name: "Milieu-Nantais", contact_telegram: "", points_reward: null, + admin_color_primary: "#7c3aed", + admin_color_secondary: "#22d3ee", + admin_color_success: "#4ade80", + admin_color_danger: "#ef4444", + admin_color_warning: "#f59e0b", + client_color_primary: "#7c3aed", + client_color_secondary: "#22d3ee", + client_color_success: "#4ade80", + client_color_danger: "#ef4444", + client_color_warning: "#f59e0b", + client_title_gradient_from: "#a78bfa", + client_title_gradient_to: "#22d3ee", }); const [showApiKey, setShowApiKey] = useState(false); const [showIpnSecret, setShowIpnSecret] = useState(false); @@ -1280,9 +1309,13 @@ export default function SettingsScreen() { } }, []); - useEffect(() => { - loadData(); - }, [loadData]); + useFocusEffect( + useCallback(() => { + if (!isDirty.current) { + loadData(); + } + }, [loadData]) + ); // Retourne l'index du pool auquel la catégorie est assignée, ou -1 si aucun const getPoolIndexFor = (catName: string): number => { @@ -1356,6 +1389,8 @@ export default function SettingsScreen() { setSaving(false); if (res.success) { isDirty.current = false; + await loadData(); + await refreshColors(); showSuccess("Succès", "Paramètres sauvegardés"); } else { showError("Erreur", res.error || "Erreur lors de la sauvegarde"); @@ -1484,6 +1519,7 @@ export default function SettingsScreen() { {/* Personnalisation */} + {/* Nom du shop */} Nom du shop @@ -1500,6 +1536,67 @@ export default function SettingsScreen() { autoCorrect={false} /> + + {/* Dégradé titre boutique */} + + Dégradé du titre boutique + Couleurs du nom de la boutique dans le header du site client. + + {/* Aperçu du dégradé */} + + + + + + + + {/* Couleur de départ */} + + + + Couleur de départ + setSettings((p) => ({ ...p, client_title_gradient_from: v }))} + placeholder="#a78bfa" + placeholderTextColor={colors.textMuted} + autoCorrect={false} + autoCapitalize="none" + maxLength={7} + /> + + + + {/* Couleur de fin */} + + + + Couleur de fin + setSettings((p) => ({ ...p, client_title_gradient_to: v }))} + placeholder="#22d3ee" + placeholderTextColor={colors.textMuted} + autoCorrect={false} + autoCapitalize="none" + maxLength={7} + /> + + + {/* Amendes */} @@ -2182,6 +2279,97 @@ export default function SettingsScreen() { )} + {/* ============================================ */} + {/* 🎨 COULEURS DE L'INTERFACE */} + {/* ============================================ */} + + + Personnalisez les couleurs de l'espace admin et de l'app client / site web. + + + showConfirm( + "Restaurer le thème d'origine", + "Toutes les couleurs personnalisées seront remplacées par le violet de base des premières versions de l'application. Continuer ?", + () => { + Keyboard.dismiss(); + setSettings((p) => ({ ...p, ...ORIGINAL_THEME_COLORS })); + }, + "Restaurer", + "Annuler", + )} + style={{ + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + marginHorizontal: spacing.l, + marginTop: spacing.m, + paddingVertical: spacing.s, + paddingHorizontal: spacing.m, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: "#7c3aed", + alignSelf: "flex-start", + }} + > + + + Restaurer les couleurs de base + + + + {(["admin", "client"] as const).map((scope) => ( + + + {scope === "admin" ? "Espace admin" : "App client & site web"} + + {([ + { label: "Principale", key: `${scope}_color_primary` as const }, + { label: "Secondaire", key: `${scope}_color_secondary` as const }, + { label: "Succès", key: `${scope}_color_success` as const }, + { label: "Danger", key: `${scope}_color_danger` as const }, + { label: "Avertissement", key: `${scope}_color_warning` as const }, + ] as { label: string; key: keyof typeof settings }[]).map(({ label, key }) => ( + + {label} + + + setSettings((p) => ({ ...p, [key]: v }))} + placeholder="#7c3aed" + placeholderTextColor={colors.textMuted} + autoCapitalize="none" + autoCorrect={false} + /> + + + {["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#f59e0b", "#4ade80", "#ef4444", "#22d3ee", "#0f172a"].map((color) => ( + setSettings((p) => ({ ...p, [key]: color }))} + style={{ + width: 26, height: 26, borderRadius: 13, + backgroundColor: color, + borderWidth: (settings[key] as string) === color ? 2.5 : 1, + borderColor: (settings[key] as string) === color ? colors.textPrimary : colors.border, + }} + /> + ))} + + + ))} + + ))} + + arr.length ? Math.max(...arr) : 1; +const maxOf = (arr: number[]) => (arr.length ? Math.max(...arr) : 1); const fmtNum = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)); const fmtEuro = (n: number) => - n >= 1000 ? `${(n / 1000).toFixed(1)}k€` : `${Math.round(n)}€`; + n >= 1000 ? `${(n / 1000).toFixed(2)}k€` : `${n.toFixed(2)}€`; // ── Barre horizontale ───────────────────────────────────────────────────────── function HBar({ - label, value, max, color, right, + label, + value, + max, + color, + right, }: { - label: string; value: number; max: number; color: string; right?: string; + label: string; + value: number; + max: number; + color: string; + right?: string; }) { const pct = max > 0 ? Math.max((value / max) * 100, value > 0 ? 2 : 0) : 0; const { colors } = useTheme(); return ( - + {label} - - + + {right ?? fmtNum(value)} @@ -53,10 +93,15 @@ function HBar({ ); } const hBarStyles = StyleSheet.create({ - row: { flexDirection: "row", alignItems: "center", marginBottom: spacing.s, gap: spacing.s }, + row: { + flexDirection: "row", + alignItems: "center", + marginBottom: spacing.s, + gap: spacing.s, + }, label: { width: 80, fontSize: fontSize.xs, flexShrink: 0 }, track: { flex: 1, height: 18, borderRadius: 4, overflow: "hidden" }, - fill: { height: "100%", borderRadius: 4 }, + fill: { height: "100%", borderRadius: 4 }, value: { width: 46, fontSize: fontSize.xs, textAlign: "right" }, }); @@ -68,13 +113,26 @@ function SparkLine({ data, color }: { data: DayStat[]; color: string }) { const BAR_H = 56; return ( - + {data.map((d, i) => ( 0 ? Math.max((d.count / max) * BAR_H, d.count > 0 ? 3 : 0) : 0, + height: + max > 0 + ? Math.max( + (d.count / max) * BAR_H, + d.count > 0 ? 3 : 0, + ) + : 0, backgroundColor: color, borderRadius: 2, opacity: 0.85, @@ -82,29 +140,58 @@ function SparkLine({ data, color }: { data: DayStat[]; color: string }) { /> ))} - - {data[0]?.label} - {data[data.length - 1]?.label} + + + {data[0]?.label} + + + {data[data.length - 1]?.label} + ); } // ── Sparkline revenus 30 jours ──────────────────────────────────────────────── -function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: string }) { +function SparkLineRevenue({ + data, + color, +}: { + data: DayRevenueStat[]; + color: string; +}) { const { colors } = useTheme(); if (!data.length) return null; const max = Math.max(...data.map((d) => d.revenue), 1); const BAR_H = 56; return ( - + {data.map((d, i) => ( 0 ? Math.max((d.revenue / max) * BAR_H, d.revenue > 0 ? 3 : 0) : 0, + height: + max > 0 + ? Math.max( + (d.revenue / max) * BAR_H, + d.revenue > 0 ? 3 : 0, + ) + : 0, backgroundColor: color, borderRadius: 2, opacity: 0.85, @@ -112,9 +199,19 @@ function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: stri /> ))} - - {data[0]?.label} - {data[data.length - 1]?.label} + + + {data[0]?.label} + + + {data[data.length - 1]?.label} + ); @@ -122,67 +219,1239 @@ function SparkLineRevenue({ data, color }: { data: DayRevenueStat[]; color: stri // ── Carte résumé ────────────────────────────────────────────────────────────── function SummaryCard({ - icon, label, value, color, + icon, + label, + value, + color, }: { - icon: keyof typeof Ionicons.glyphMap; label: string; value: string; color: string; + icon: keyof typeof Ionicons.glyphMap; + label: string; + value: string; + color: string; }) { const { colors } = useTheme(); return ( - - + + - {value} - {label} + + {value} + + + {label} + ); } const sumStyles = StyleSheet.create({ - card: { flex: 1, borderRadius: borderRadius.md, padding: spacing.m, borderWidth: 1, alignItems: "center", minWidth: "45%" }, - iconWrap:{ width: 36, height: 36, borderRadius: 18, justifyContent: "center", alignItems: "center", marginBottom: spacing.xs }, - val: { fontSize: fontSize.lg, fontWeight: "700" }, - lbl: { fontSize: fontSize.xs, marginTop: 2, textAlign: "center" }, + card: { + flex: 1, + borderRadius: borderRadius.md, + padding: spacing.m, + borderWidth: 1, + alignItems: "center", + minWidth: "45%", + }, + iconWrap: { + width: 36, + height: 36, + borderRadius: 18, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.xs, + }, + val: { fontSize: fontSize.lg, fontWeight: "700" }, + lbl: { fontSize: fontSize.xs, marginTop: 2, textAlign: "center" }, }); // ── Séparateur de section ───────────────────────────────────────────────────── -function Section({ title, icon, children }: { title: string; icon: keyof typeof Ionicons.glyphMap; children: React.ReactNode }) { +function Section({ + title, + icon, + children, +}: { + title: string; + icon: keyof typeof Ionicons.glyphMap; + children: React.ReactNode; +}) { const { colors } = useTheme(); return ( - + - {title} + + {title} + {children} ); } const secStyles = StyleSheet.create({ - card: { borderRadius: borderRadius.md, padding: spacing.l, marginBottom: spacing.m, borderWidth: 1 }, - header: { flexDirection: "row", alignItems: "center", gap: spacing.s, marginBottom: spacing.m }, - title: { fontSize: fontSize.md, fontWeight: "600" }, + card: { + borderRadius: borderRadius.md, + padding: spacing.l, + marginBottom: spacing.m, + borderWidth: 1, + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: spacing.s, + marginBottom: spacing.m, + }, + title: { fontSize: fontSize.md, fontWeight: "600" }, }); +// ── Bouton reset de section ─────────────────────────────────────────────────── +function SectionResetBtn({ + onPress, + loading, + resetAt, +}: { + onPress: () => void; + loading: boolean; + resetAt?: string; +}) { + const { colors } = useTheme(); + return ( + + + + + {loading ? "..." : "Réinitialiser"} + + + {resetAt ? ( + + Depuis le{" "} + {new Date(resetAt).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + })} + + ) : null} + + ); +} + +// ── Contenu détaillé d'un jour (catégories → produits) ─────────────────────── +// Réutilisé à la fois par la section "Activité du jour" (aujourd'hui) et par +// la modal de détail d'un jour cliqué dans l'historique mensuel. +function DailyDetailContent({ daily }: { daily: DailyDetail }) { + const { colors } = useTheme(); + const hasData = daily.categories.length > 0; + + return ( + <> + {/* Mini-résumé */} + + + + + {daily.total_orders} + + + commandes + + + + + + {daily.total_quantity}g + + + vendues + + + + + + {fmtEuro(daily.total_revenue)} + + + revenus + + + + + {!hasData ? ( + + Aucune commande ce jour-là + + ) : ( + daily.categories.map((cat: DailyCategoryDetail, ci: number) => { + const catMax = Math.max( + ...cat.products.map((p) => p.quantity), + 1, + ); + const isLast = ci === daily.categories.length - 1; + return ( + + {/* En-tête catégorie */} + + + + + {cat.category} + + + + + {cat.total_quantity}g + + + {fmtEuro(cat.total_revenue)} + + + + + {/* Lignes produits */} + {cat.products.map((prod, pi) => { + const pct = + catMax > 0 + ? Math.max( + (prod.quantity / catMax) * 100, + prod.quantity > 0 ? 3 : 0, + ) + : 0; + const isTopInCat = pi === 0; + return ( + + + + {prod.name} + + + + {prod.quantity}g ·{" "} + {prod.order_count} cmd + + + {fmtEuro(prod.revenue)} + + + + + + + + ); + })} + + ); + }) + )} + + ); +} + +// ── Section détail du jour (aujourd'hui, dans le flux principal) ──────────── +function DailyDetailSection({ daily }: { daily: DailyDetail }) { + const { colors } = useTheme(); + + return ( + + {/* En-tête */} + + + + + Activité du jour + + + + {daily.date} + + + + + + ); +} + +const ddStyles = StyleSheet.create({ + card: { + borderRadius: borderRadius.md, + padding: spacing.l, + marginBottom: spacing.m, + borderWidth: 1, + }, + header: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: spacing.m, + }, + title: { fontSize: fontSize.md, fontWeight: "600" }, + date: { fontSize: fontSize.xs, fontWeight: "500" }, + chipRow: { flexDirection: "row", gap: spacing.s, marginBottom: spacing.l }, + chip: { + flex: 1, + alignItems: "center", + gap: 3, + paddingVertical: spacing.s, + borderRadius: borderRadius.sm, + borderWidth: 1, + }, + chipVal: { fontSize: fontSize.sm, fontWeight: "700" }, + chipLbl: { fontSize: 9, fontWeight: "500" }, + empty: { + textAlign: "center", + fontSize: fontSize.sm, + paddingVertical: spacing.l, + }, + catBlock: { + marginBottom: spacing.l, + paddingBottom: spacing.l, + borderBottomWidth: 1, + }, + catHeader: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: spacing.s, + paddingBottom: spacing.xs, + }, + catDot: { width: 10, height: 10, borderRadius: 5 }, + catName: { fontSize: fontSize.sm, fontWeight: "700" }, + catMeta: { fontSize: fontSize.xs }, + catRevenue: { fontSize: fontSize.xs, fontWeight: "700" }, + prodRow: { marginBottom: spacing.s }, + prodTopLine: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: 4, + }, + prodName: { flex: 1, fontSize: fontSize.xs, marginRight: spacing.s }, + prodMeta: { flexDirection: "row", alignItems: "center", gap: spacing.m }, + prodQty: { fontSize: fontSize.xs }, + prodRevenue: { + fontSize: fontSize.xs, + fontWeight: "600", + minWidth: 52, + textAlign: "right", + }, + bar: { height: 5, borderRadius: 3, overflow: "hidden" }, + barFill: { height: "100%", borderRadius: 3 }, +}); + +// ── Modal — détail d'un jour cliqué dans l'historique mensuel ─────────────── +function DayDetailModal({ + visible, + dateLabel, + onClose, +}: { + visible: boolean; + dateLabel: string | null; // "2026-06-05" + onClose: () => void; +}) { + const { colors } = useTheme(); + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [errored, setErrored] = useState(false); + + useEffect(() => { + if (!visible || !dateLabel) return; + setLoading(true); + setErrored(false); + getAdminDailyDetail(dateLabel) + .then((data) => setDetail(data)) + .catch(() => setErrored(true)) + .finally(() => setLoading(false)); + }, [visible, dateLabel]); + + const prettyDate = useMemo(() => { + if (!dateLabel) return ""; + const [y, m, d] = dateLabel.split("-").map(Number); + return new Date(y, m - 1, d).toLocaleDateString("fr-FR", { + weekday: "long", + day: "2-digit", + month: "long", + year: "numeric", + }); + }, [dateLabel]); + + return ( + + + + + + + + {prettyDate} + + + + + + + + + {loading ? ( + + Chargement... + + ) : errored ? ( + + Impossible de charger le détail de ce jour + + ) : detail ? ( + + ) : null} + + + + + ); +} + +const dayModalStyles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.55)", + justifyContent: "center", + padding: spacing.l, + }, + sheet: { + borderRadius: borderRadius.md, + borderWidth: 1, + padding: spacing.l, + maxHeight: "85%", + }, + header: { + flexDirection: "row", + alignItems: "center", + marginBottom: spacing.m, + }, + title: { + fontSize: fontSize.md, + fontWeight: "700", + textTransform: "capitalize", + }, + closeBtn: { + padding: spacing.xs, + }, +}); + +// ── Helpers de mois (clé "YYYY-MM") ─────────────────────────────────────────── +function monthKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + return `${y}-${m}`; +} +function monthLabel(key: string): string { + const [y, m] = key.split("-").map(Number); + const d = new Date(y, m - 1, 1); + return d.toLocaleDateString("fr-FR", { month: "long", year: "numeric" }); +} +function shiftMonth(key: string, delta: number): string { + const [y, m] = key.split("-").map(Number); + const d = new Date(y, m - 1 + delta, 1); + return monthKey(d); +} + +// ── Section historique mensuel (jour par jour sur un mois) ────────────────── +function MonthlyHistorySection() { + const { colors } = useTheme(); + const [monthKeyState, setMonthKeyState] = useState(() => + monthKey(new Date()), + ); + const [monthly, setMonthly] = useState(null); + const [loadingMonth, setLoadingMonth] = useState(false); + const [expanded, setExpanded] = useState(false); + const [selectedDay, setSelectedDay] = useState(null); + + const currentMonthKey = monthKey(new Date()); + const isCurrentMonth = monthKeyState === currentMonthKey; + + const loadMonth = useCallback(async (key: string) => { + setLoadingMonth(true); + try { + const data = await getAdminStatsByMonth(key); + setMonthly(data); + } catch { + setMonthly(null); + } + setLoadingMonth(false); + }, []); + + useEffect(() => { + if (expanded) loadMonth(monthKeyState); + }, [expanded, monthKeyState, loadMonth]); + + const goPrevMonth = () => setMonthKeyState((k) => shiftMonth(k, -1)); + const goNextMonth = () => setMonthKeyState((k) => shiftMonth(k, 1)); + + const days = monthly?.by_day ?? []; + const revMax = Math.max(...days.map((d) => d.revenue), 1); + const cntMax = Math.max(...days.map((d) => d.count), 1); + const best = days.length + ? [...days].sort((a, b) => b.revenue - a.revenue)[0] + : null; + + return ( + + setExpanded((e) => !e)} + style={{ + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + }} + > + + + + Historique mensuel + + + + + + {expanded && ( + <> + {/* Sélecteur de mois */} + + + + + + {monthLabel(monthKeyState)} + + + + + + + {loadingMonth ? ( + + Chargement... + + ) : !days.length ? ( + + Aucune donnée pour ce mois + + ) : ( + <> + {/* Mini-résumé du mois */} + + + + + {monthly?.summary.total_orders ?? 0} + + + commandes + + + + + + {fmtNum( + monthly?.summary.total_quantity ?? + 0, + )} + g + + + vendues + + + + + + {fmtEuro( + monthly?.summary.total_revenue ?? 0, + )} + + + revenus + + + + + {/* Sparkline revenus du mois */} + + {days.map((d) => ( + 0 ? 3 : 0, + ), + backgroundColor: + best && d.day === best.day + ? CHART_AMBER + : CHART_GREEN, + borderRadius: 2, + opacity: 0.85, + }} + /> + ))} + + + + {days[0]?.label} + + + {days[days.length - 1]?.label} + + + + {/* Liste détaillée jour par jour — cliquable */} + {[...days].reverse().map((d) => { + const isBest = best && d.day === best.day; + const pct = + cntMax > 0 + ? Math.max( + (d.count / cntMax) * 100, + d.count > 0 ? 3 : 0, + ) + : 0; + const hasData = d.count > 0; + return ( + + hasData && setSelectedDay(d.day) + } + disabled={!hasData} + activeOpacity={0.6} + style={{ + flexDirection: "row", + alignItems: "center", + marginBottom: spacing.s, + gap: spacing.s, + }} + > + + {d.label} + + + + + + {d.count} + + + {fmtNum(d.quantity)}g + + + {fmtEuro(d.revenue)} + + {isBest && ( + + )} + {hasData && ( + + )} + + ); + })} + + {best && ( + + + + Meilleure journée du mois :{" "} + + {best.label} ·{" "} + {fmtEuro(best.revenue)} + + + + )} + + )} + + )} + + setSelectedDay(null)} + /> + + ); +} + // ── Sélecteur de période top produits ──────────────────────────────────────── type ProdSort = "quantity" | "orders" | "revenue"; // ═══════════════════════════════════════════════════════════════════════════════ export default function StatsScreen() { const { colors } = useTheme(); - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); + const [resetting, setResetting] = useState(null); const [prodSort, setProdSort] = useState("quantity"); + const [confirmModal, setConfirmModal] = useState<{ + section: StatSection; + label: string; + } | null>(null); + const [errorModal, setErrorModal] = useState(null); const loadStats = useCallback(async () => { try { const data = await getAdminStats(); setStats(data); - } catch { /* ignore */ } + } catch { + /* ignore */ + } setLoading(false); }, []); - useFocusEffect(useCallback(() => { loadStats(); }, [loadStats])); + useFocusEffect( + useCallback(() => { + loadStats(); + }, [loadStats]), + ); const onRefresh = async () => { setRefreshing(true); @@ -190,311 +1459,727 @@ export default function StatsScreen() { setRefreshing(false); }; + const handleReset = (section: StatSection, label: string) => { + setConfirmModal({ section, label }); + }; + + const doReset = async () => { + if (!confirmModal) return; + const { section } = confirmModal; + setConfirmModal(null); + setResetting(section); + try { + await resetAdminStats(section); + await loadStats(); + } catch (e: any) { + setErrorModal( + e?.response?.data?.error || + e?.message || + "Impossible de réinitialiser", + ); + } + setResetting(null); + }; + // Produits triés selon le sélecteur const sortedProducts = useMemo(() => { if (!stats?.top_products) return []; return [...stats.top_products].sort((a, b) => { - if (prodSort === "orders") return b.order_count - a.order_count; + if (prodSort === "orders") return b.order_count - a.order_count; if (prodSort === "revenue") return b.revenue - a.revenue; return b.quantity - a.quantity; }); }, [stats, prodSort]); - const styles = useMemo(() => StyleSheet.create({ - container: { flex: 1, backgroundColor: colors.bgPrimary }, - content: { padding: spacing.l, paddingBottom: spacing.xxxl }, - pageTitle: { fontSize: fontSize.xl, fontWeight: "700", color: colors.textPrimary, marginBottom: spacing.xs }, - pageSubtitle: { fontSize: fontSize.sm, color: colors.textMuted, marginBottom: spacing.l }, - summaryRow: { flexDirection: "row", gap: spacing.m, marginBottom: spacing.m, flexWrap: "wrap" }, - sortRow: { flexDirection: "row", gap: spacing.s, marginBottom: spacing.m }, - sortBtn: { paddingHorizontal: spacing.m, paddingVertical: spacing.xs, borderRadius: borderRadius.sm, borderWidth: 1 }, - sortBtnText:{ fontSize: fontSize.xs, fontWeight: "600" }, - emptyText: { color: colors.textMuted, fontSize: fontSize.sm, textAlign: "center", paddingVertical: spacing.l }, - }), [colors]); + const styles = useMemo( + () => + StyleSheet.create({ + container: { flex: 1, backgroundColor: colors.bgPrimary }, + content: { padding: spacing.l, paddingBottom: spacing.xxxl }, + pageTitle: { + fontSize: fontSize.xl, + fontWeight: "700", + color: colors.textPrimary, + marginBottom: spacing.xs, + }, + pageSubtitle: { + fontSize: fontSize.sm, + color: colors.textMuted, + marginBottom: spacing.l, + }, + summaryRow: { + flexDirection: "row", + gap: spacing.m, + marginBottom: spacing.m, + flexWrap: "wrap", + }, + sortRow: { + flexDirection: "row", + gap: spacing.s, + marginBottom: spacing.m, + }, + sortBtn: { + paddingHorizontal: spacing.m, + paddingVertical: spacing.xs, + borderRadius: borderRadius.sm, + borderWidth: 1, + }, + sortBtnText: { fontSize: fontSize.xs, fontWeight: "600" }, + emptyText: { + color: colors.textMuted, + fontSize: fontSize.sm, + textAlign: "center", + paddingVertical: spacing.l, + }, + headerRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: spacing.xs, + }, + resetBtn: { + flexDirection: "row", + alignItems: "center", + gap: 4, + paddingHorizontal: spacing.s, + paddingVertical: 3, + borderRadius: borderRadius.sm, + borderWidth: 1, + borderColor: CHART_RED + "66", + backgroundColor: CHART_RED + "11", + }, + resetBtnText: { + fontSize: 10, + fontWeight: "600", + color: CHART_RED, + }, + resetDate: { + fontSize: fontSize.xs, + color: colors.textMuted, + marginBottom: spacing.l, + }, + }), + [colors], + ); - if (loading) return ; + if (loading) + return ; const s = stats?.summary; - const wdMax = maxOf((stats?.by_weekday ?? []).map((w: WeekdayStat) => w.count)); - const prodMax = maxOf(sortedProducts.map((p) => - prodSort === "orders" ? p.order_count : prodSort === "revenue" ? p.revenue : p.quantity, - )); + const wdMax = maxOf( + (stats?.by_weekday ?? []).map((w: WeekdayStat) => w.count), + ); + const prodMax = maxOf( + sortedProducts.map((p) => + prodSort === "orders" + ? p.order_count + : prodSort === "revenue" + ? p.revenue + : p.quantity, + ), + ); return ( - } - > - Statistiques - Activité globale & produits - - {/* ── Cartes résumé ── */} - - - - - - - - - - {/* ── Évolution 30 jours (commandes) ── */} -
- {stats?.by_day_30?.length ? ( - - ) : ( - Aucune donnée - )} -
- - {/* ── Revenus par jour (30 jours) ── */} - {(() => { - const revData = stats?.by_day_revenue ?? []; - const revMax = Math.max(...revData.map((d) => d.revenue), 1); - const best = [...revData].sort((a, b) => b.revenue - a.revenue)[0]; - return ( -
- {revData.length ? ( - <> - - - {[...revData].reverse().map((d) => ( - - ))} - - {best && ( - - - - Meilleure journée :{" "} - - {best.label} · {fmtEuro(best.revenue)} - - - - )} - - ) : ( - Aucune donnée de revenu - )} -
- ); - })()} - - {/* ── Heures d'affluence ── */} - {(() => { - const hourData: HourStat[] = stats?.by_hour ?? []; - const peakHour = hourData.reduce( - (best, h) => (!best || h.count > best.count ? h : best), - null, - ); - const hourMax = maxOf(hourData.map((h) => h.count)); - return ( -
- {hourData.some((h) => h.count > 0) ? ( - <> - {hourData.map((h) => ( - 0 ? CHART_AMBER : CHART_BLUE} - right={h.count > 0 ? String(h.count) : "—"} - /> - ))} - {peakHour && peakHour.count > 0 && ( - - - - Heure de pointe :{" "} - - {peakHour.label} · {peakHour.count} cmd · {fmtEuro(peakHour.revenue)} - - - - )} - - ) : ( - Aucune donnée horaire - )} -
- ); - })()} - - {/* ── Commandes par jour de la semaine ── */} -
- {(stats?.by_weekday ?? []).map((w: WeekdayStat) => ( - 0 ? CHART_AMBER : CHART_ACCENT} - right={String(w.count)} + <> + setConfirmModal(null)} + onConfirm={doReset} + /> + setErrorModal(null)} + /> + - ))} - {wdMax > 0 && s?.peak_weekday && ( - - - - Pic d'activité : {s.peak_weekday} - - - )} -
+ } + > + Statistiques + + Activité globale & produits + - {/* ── Doses les plus populaires par produit ── */} - {(() => { - const qtyData: ProductQuantityBreakdown[] = (stats?.by_quantity ?? []).filter( - (p) => p.quantities.length >= 1, - ); - if (!qtyData.length) return null; - return ( -
- {qtyData.map((product) => { - const peakCount = product.quantities[0]?.order_count ?? 1; - return ( - - {/* Nom du produit avec pastille couleur */} - - - - {product.name} - - - {/* Barre par dose */} - {product.quantities.map((q, i) => { - const pct = peakCount > 0 ? Math.max((q.order_count / peakCount) * 100, q.order_count > 0 ? 2 : 0) : 0; - const isPeak = i === 0; - const label = Number.isInteger(q.quantity) - ? `${q.quantity}g` - : `${q.quantity}g`; - return ( - - - {label} - - - - - - {q.order_count} cmd - - {isPeak && ( - - )} - - ); - })} - - ); - })} -
- ); - })()} - - {/* ── Top produits ── */} -
- {/* Sélecteur tri */} - - {(["quantity", "orders", "revenue"] as ProdSort[]).map((key) => { - const labels = { quantity: "Quantité", orders: "Commandes", revenue: "Revenus" }; - const active = prodSort === key; - return ( - setProdSort(key)} - > - - {labels[key]} - - - ); - })} + {/* ── Cartes résumé ── */} + + + + + + + - {sortedProducts.length === 0 ? ( - Aucune donnée produit - ) : ( - sortedProducts.map((p) => { - const val = - prodSort === "orders" ? p.order_count - : prodSort === "revenue" ? p.revenue - : p.quantity; - const rightLabel = - prodSort === "revenue" ? fmtEuro(p.revenue) - : prodSort === "orders" ? `${p.order_count} cmd` - : `×${fmtNum(p.quantity)}`; - return ( - - ); - }) + {/* ── Activité du jour ── */} + {stats?.daily_detail && ( + )} - {/* Produit le moins vendu */} - {sortedProducts.length > 1 && prodSort === "quantity" && ( - - - - Moins vendu :{" "} - - {sortedProducts[sortedProducts.length - 1]?.name} + {/* ── Historique mensuel (jour par jour sur un mois) ── */} + + + {/* ── Évolution 30 jours (commandes) ── */} +
+ handleReset("commandes", "Commandes")} + loading={resetting === "commandes"} + resetAt={stats?.reset_at_commandes} + /> + {stats?.by_day_30?.length ? ( + + ) : ( + Aucune donnée + )} +
+ + {/* ── Revenus par jour (30 jours) ── */} + {(() => { + const revData = stats?.by_day_revenue ?? []; + const revMax = Math.max( + ...revData.map((d) => d.revenue), + 1, + ); + const best = [...revData].sort( + (a, b) => b.revenue - a.revenue, + )[0]; + return ( +
+ + handleReset("revenus", "Revenus") + } + loading={resetting === "revenus"} + resetAt={stats?.reset_at_revenus} + /> + {revData.length ? ( + <> + + + {[...revData].reverse().map((d) => ( + + ))} + + {best && ( + + + + Meilleure journée :{" "} + + {best.label} ·{" "} + {fmtEuro(best.revenue)} + + + + )} + + ) : ( + + Aucune donnée de revenu + + )} +
+ ); + })()} + + {/* ── Heures d'affluence ── */} + {(() => { + const hourData: HourStat[] = stats?.by_hour ?? []; + const peakHour = hourData.reduce( + (best, h) => (!best || h.count > best.count ? h : best), + null, + ); + const hourMax = maxOf(hourData.map((h) => h.count)); + return ( +
+ + handleReset("heures", "Heures d'affluence") + } + loading={resetting === "heures"} + resetAt={stats?.reset_at_heures} + /> + {hourData.some((h) => h.count > 0) ? ( + <> + {hourData.map((h) => ( + 0 + ? CHART_AMBER + : CHART_BLUE + } + right={ + h.count > 0 + ? String(h.count) + : "—" + } + /> + ))} + {peakHour && peakHour.count > 0 && ( + + + + Heure de pointe :{" "} + + {peakHour.label} ·{" "} + {peakHour.count} cmd ·{" "} + {fmtEuro(peakHour.revenue)} + + + + )} + + ) : ( + + Aucune donnée horaire + + )} +
+ ); + })()} + + {/* ── Commandes par jour de la semaine ── */} +
+ + handleReset("jours", "Jours d'affluence") + } + loading={resetting === "jours"} + resetAt={stats?.reset_at_jours} + /> + {(stats?.by_weekday ?? []).map((w: WeekdayStat) => ( + 0 + ? CHART_AMBER + : CHART_ACCENT + } + right={String(w.count)} + /> + ))} + {wdMax > 0 && s?.peak_weekday && ( + + + + Pic d'activité :{" "} + + {s.peak_weekday} + - + + )} +
+ + {/* ── Doses les plus populaires par produit ── */} + {(() => { + const qtyData: ProductQuantityBreakdown[] = ( + stats?.by_quantity ?? [] + ).filter((p) => p.quantities.length >= 1); + if (!qtyData.length) return null; + return ( +
+ + handleReset("doses", "Doses populaires") + } + loading={resetting === "doses"} + resetAt={stats?.reset_at_doses} + /> + {qtyData.map((product) => { + const peakCount = + product.quantities[0]?.order_count ?? 1; + return ( + + {/* Nom du produit avec pastille couleur */} + + + + {product.name} + + + {/* Barre par dose */} + {product.quantities.map((q, i) => { + const pct = + peakCount > 0 + ? Math.max( + (q.order_count / + peakCount) * + 100, + q.order_count > 0 + ? 2 + : 0, + ) + : 0; + const isPeak = i === 0; + const label = Number.isInteger( + q.quantity, + ) + ? `${q.quantity}g` + : `${q.quantity}g`; + return ( + + + {label} + + + + + + {q.order_count} cmd + + {isPeak && ( + + )} + + ); + })} + + ); + })} +
+ ); + })()} + + {/* ── Top produits ── */} +
+ handleReset("produits", "Produits")} + loading={resetting === "produits"} + resetAt={stats?.reset_at_produits} + /> + {/* Sélecteur tri */} + + {(["quantity", "orders", "revenue"] as ProdSort[]).map( + (key) => { + const labels = { + quantity: "Quantité", + orders: "Commandes", + revenue: "Revenus", + }; + const active = prodSort === key; + return ( + setProdSort(key)} + > + + {labels[key]} + + + ); + }, + )} - )} -
- + + {sortedProducts.length === 0 ? ( + + Aucune donnée produit + + ) : ( + sortedProducts.map((p) => { + const val = + prodSort === "orders" + ? p.order_count + : prodSort === "revenue" + ? p.revenue + : p.quantity; + const rightLabel = + prodSort === "revenue" + ? fmtEuro(p.revenue) + : prodSort === "orders" + ? `${p.order_count} cmd` + : `×${fmtNum(p.quantity)}`; + return ( + + ); + }) + )} + + {/* Produit le moins vendu */} + {sortedProducts.length > 1 && prodSort === "quantity" && ( + + + + Moins vendu :{" "} + + { + sortedProducts[ + sortedProducts.length - 1 + ]?.name + } + + + + )} +
+
+ ); } diff --git a/frontend-admin/src/screens/admin/UsersScreen.tsx b/frontend-admin/src/screens/admin/UsersScreen.tsx index ebd89d23..f753a4a7 100644 --- a/frontend-admin/src/screens/admin/UsersScreen.tsx +++ b/frontend-admin/src/screens/admin/UsersScreen.tsx @@ -24,6 +24,7 @@ import { createClientByAdmin, createUserByAdmin, setClientParrain, + getClientParrain, creditClientReferral, resetClientReferral, getClientCancelledOrders, @@ -33,8 +34,9 @@ import { resetClientPoints, addClientPoints, subtractClientPoints, + getLivreurLoginHistory, } from "../../api/api_admin"; -import type { CancelledOrder } from "../../api/api_admin"; +import type { CancelledOrder, LoginHistoryWeek } from "../../api/api_admin"; import type { ClientResponse } from "../../api/types"; import LoadingSpinner from "../../components/ui/LoadingSpinner"; import Card from "../../components/ui/Card"; @@ -147,6 +149,9 @@ export default function UsersScreen() { const [referralEnabled, setReferralEnabled] = useState(true); const [filter, setFilter] = useState("all"); const [search, setSearch] = useState(""); + const [parrainMap, setParrainMap] = useState>( + {}, + ); // Edit client modal const [editClientModal, setEditClientModal] = useState<{ @@ -180,7 +185,6 @@ export default function UsersScreen() { const [createTel, setCreateTel] = useState(""); const [createParrain, setCreateParrain] = useState(""); const [creating, setCreating] = useState(false); - // Referral credit modal const [referralModal, setReferralModal] = useState<{ visible: boolean; @@ -195,14 +199,24 @@ export default function UsersScreen() { visible: boolean; client: ClientResponse | null; }>({ visible: false, client: null }); - const [sanctionTab, setSanctionTab] = useState<"amende" | "points">("amende"); + const [sanctionTab, setSanctionTab] = useState<"amende" | "points">( + "amende", + ); const [amendeAmount, setAmendeAmount] = useState(""); const [amendeReason, setAmendeReason] = useState(""); const [amendeLoading, setAmendeLoading] = useState(false); - const [pointsInputs, setPointsInputs] = useState>({}); - const [pointsLoading, setPointsLoading] = useState>({}); - const [subtractInputs, setSubtractInputs] = useState>({}); - const [subtractLoading, setSubtractLoading] = useState>({}); + const [pointsInputs, setPointsInputs] = useState>( + {}, + ); + const [pointsLoading, setPointsLoading] = useState>( + {}, + ); + const [subtractInputs, setSubtractInputs] = useState< + Record + >({}); + const [subtractLoading, setSubtractLoading] = useState< + Record + >({}); // Cancelled orders modal const [cancelledModal, setCancelledModal] = useState<{ @@ -212,7 +226,25 @@ export default function UsersScreen() { loading: boolean; }>({ visible: false, username: "", orders: [], loading: false }); - const { alert, showError, showSuccess, showConfirm, hideAlert } = useAlert(); + // Login history modal (livreur) + const [loginHistoryModal, setLoginHistoryModal] = useState<{ + visible: boolean; + username: string; + year: number; + month: number; + weeks: LoginHistoryWeek[]; + loading: boolean; + }>({ + visible: false, + username: "", + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + weeks: [], + loading: false, + }); + + const { alert, showError, showSuccess, showConfirm, hideAlert } = + useAlert(); // -------------------------------------------------- // Data @@ -231,8 +263,34 @@ export default function UsersScreen() { setPoolNames(pools.map((p) => p.name)); setPoolKeys(pools.map((p) => p.key)); setPointsEnabled(settingsRes.settings.points_enabled ?? true); - setAmendeEnabled(settingsRes.settings.show_amende_score ?? true); - setReferralEnabled(settingsRes.settings.referral_enabled ?? true); + setAmendeEnabled( + settingsRes.settings.show_amende_score ?? true, + ); + setReferralEnabled( + settingsRes.settings.referral_enabled ?? true, + ); + } + + // Charger le parrain de chaque client en parallèle (best-effort, + // une erreur sur un client n'empêche pas les autres de s'afficher). + const clientUsernames = (clientsData ?? []).map((c) => c.username); + if (clientUsernames.length > 0) { + const results = await Promise.all( + clientUsernames.map(async (username) => { + const res = await getClientParrain(username); + return [ + username, + res.success ? (res.parrain ?? null) : null, + ] as const; + }), + ); + const map: Record = {}; + for (const [username, parrain] of results) { + map[username] = parrain; + } + setParrainMap(map); + } else { + setParrainMap({}); } } catch { /* ignore */ @@ -332,7 +390,10 @@ export default function UsersScreen() { const handleSaveClient = async () => { if (!editClientModal.client) return; if (editClientPassword.trim() && editClientPassword.trim().length < 8) { - showError("Erreur", "Le mot de passe doit contenir au moins 8 caractères"); + showError( + "Erreur", + "Le mot de passe doit contenir au moins 8 caractères", + ); return; } try { @@ -366,7 +427,10 @@ export default function UsersScreen() { const handleSaveUser = async () => { if (!editUserModal.user) return; if (editUserPassword.trim() && editUserPassword.trim().length < 8) { - showError("Erreur", "Le mot de passe doit faire au moins 8 caractères"); + showError( + "Erreur", + "Le mot de passe doit faire au moins 8 caractères", + ); return; } try { @@ -439,7 +503,10 @@ export default function UsersScreen() { } setReferralLoading(true); try { - const res = await creditClientReferral(referralModal.username, amount); + const res = await creditClientReferral( + referralModal.username, + amount, + ); if (res.success) { setReferralModal((prev) => ({ ...prev, visible: false })); await loadData(); @@ -497,15 +564,28 @@ export default function UsersScreen() { `Ajouter ${pts} € d'amende à "${sanctionModal.client.username}" ?\nRaison : ${amendeReason.trim()}`, async () => { setAmendeLoading(true); - const res = await applyClientPenalty(sanctionModal.client!.username, pts, amendeReason.trim()); + const res = await applyClientPenalty( + sanctionModal.client!.username, + pts, + amendeReason.trim(), + ); setAmendeLoading(false); - if (!res.success) { showError("Erreur", res.error || "Echec"); return; } + if (!res.success) { + showError("Erreur", res.error || "Echec"); + return; + } setAmendeAmount(""); setAmendeReason(""); if (res.compensated) { - showSuccess("Compensation", "Amende annulée — solde parrainage débité en compensation"); + showSuccess( + "Compensation", + "Amende annulée — solde parrainage débité en compensation", + ); } else { - showSuccess("Validé", `Amende de ${pts} € appliquée à "${sanctionModal.client!.username}"`); + showSuccess( + "Validé", + `Amende de ${pts} € appliquée à "${sanctionModal.client!.username}"`, + ); } await loadData(); }, @@ -518,9 +598,18 @@ export default function UsersScreen() { "Remettre à zéro", `Remettre l'amende de "${sanctionModal.client.username}" à 0 ?`, async () => { - const res = await resetClientPenalties(sanctionModal.client!.username, false); - if (!res.success) { showError("Erreur", res.error || "Echec"); return; } - showSuccess("Validé", `Amende de "${sanctionModal.client!.username}" remise à zéro`); + const res = await resetClientPenalties( + sanctionModal.client!.username, + false, + ); + if (!res.success) { + showError("Erreur", res.error || "Echec"); + return; + } + showSuccess( + "Validé", + `Amende de "${sanctionModal.client!.username}" remise à zéro`, + ); await loadData(); }, "Remettre à zéro", @@ -539,11 +628,21 @@ export default function UsersScreen() { `Ajouter ${pts} points "${poolName}" à "${sanctionModal.client.username}" ?`, async () => { setPointsLoading((prev) => ({ ...prev, [poolKey]: true })); - const res = await addClientPoints(sanctionModal.client!.username, poolKey, pts); + const res = await addClientPoints( + sanctionModal.client!.username, + poolKey, + pts, + ); setPointsLoading((prev) => ({ ...prev, [poolKey]: false })); - if (!res.success) { showError("Erreur", res.error || "Echec"); return; } + if (!res.success) { + showError("Erreur", res.error || "Echec"); + return; + } setPointsInputs((prev) => ({ ...prev, [poolKey]: "" })); - showSuccess("Validé", `${pts} points "${poolName}" ajoutés à "${sanctionModal.client!.username}"`); + showSuccess( + "Validé", + `${pts} points "${poolName}" ajoutés à "${sanctionModal.client!.username}"`, + ); await loadData(); }, ); @@ -561,25 +660,48 @@ export default function UsersScreen() { `Retirer ${pts} points "${poolName}" à "${sanctionModal.client.username}" ?`, async () => { setSubtractLoading((prev) => ({ ...prev, [poolKey]: true })); - const res = await subtractClientPoints(sanctionModal.client!.username, poolKey, pts); + const res = await subtractClientPoints( + sanctionModal.client!.username, + poolKey, + pts, + ); setSubtractLoading((prev) => ({ ...prev, [poolKey]: false })); - if (!res.success) { showError("Erreur", res.error || "Echec"); return; } + if (!res.success) { + showError("Erreur", res.error || "Echec"); + return; + } setSubtractInputs((prev) => ({ ...prev, [poolKey]: "" })); - showSuccess("Validé", `${pts} points "${poolName}" retirés à "${sanctionModal.client!.username}"`); + showSuccess( + "Validé", + `${pts} points "${poolName}" retirés à "${sanctionModal.client!.username}"`, + ); await loadData(); }, ); }; - const handleResetPoints = (poolKey: string, poolName: string, poolIdx: number) => { + const handleResetPoints = ( + poolKey: string, + poolName: string, + poolIdx: number, + ) => { if (!sanctionModal.client) return; showConfirm( "Remettre à zéro", `Remettre les points "${poolName}" de "${sanctionModal.client.username}" à 0 ?`, async () => { - const res = await resetClientPoints(sanctionModal.client!.username, poolIdx); - if (!res.success) { showError("Erreur", res.error || "Echec"); return; } - showSuccess("Validé", `Points "${poolName}" de "${sanctionModal.client!.username}" remis à zéro`); + const res = await resetClientPoints( + sanctionModal.client!.username, + poolIdx, + ); + if (!res.success) { + showError("Erreur", res.error || "Echec"); + return; + } + showSuccess( + "Validé", + `Points "${poolName}" de "${sanctionModal.client!.username}" remis à zéro`, + ); await loadData(); }, "Remettre à zéro", @@ -590,9 +712,65 @@ export default function UsersScreen() { // Cancelled orders // -------------------------------------------------- const openCancelledOrders = async (client: ClientResponse) => { - setCancelledModal({ visible: true, username: client.username, orders: [], loading: true }); + setCancelledModal({ + visible: true, + username: client.username, + orders: [], + loading: true, + }); const res = await getClientCancelledOrders(client.username); - setCancelledModal((prev) => ({ ...prev, orders: res.orders, loading: false })); + setCancelledModal((prev) => ({ + ...prev, + orders: res.orders, + loading: false, + })); + }; + + // -------------------------------------------------- + // Login history (livreur) + // -------------------------------------------------- + const fetchLoginHistory = async ( + username: string, + year: number, + month: number, + ) => { + setLoginHistoryModal((prev) => ({ ...prev, loading: true })); + const res = await getLivreurLoginHistory(username, year, month); + setLoginHistoryModal((prev) => ({ + ...prev, + year: res.year, + month: res.month, + weeks: res.weeks, + loading: false, + })); + }; + + const openLoginHistory = (username: string) => { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + setLoginHistoryModal({ + visible: true, + username, + year, + month, + weeks: [], + loading: true, + }); + fetchLoginHistory(username, year, month); + }; + + const changeLoginHistoryMonth = (delta: number) => { + let year = loginHistoryModal.year; + let month = loginHistoryModal.month + delta; + if (month < 1) { + month = 12; + year -= 1; + } else if (month > 12) { + month = 1; + year += 1; + } + fetchLoginHistory(loginHistoryModal.username, year, month); }; // -------------------------------------------------- @@ -646,7 +824,10 @@ export default function UsersScreen() { telephone: createTel.trim(), }); if (createParrain.trim()) { - await setClientParrain(usernameToCreate, createParrain.trim()); + await setClientParrain( + usernameToCreate, + createParrain.trim(), + ); } } else { await createUserByAdmin({ @@ -665,14 +846,22 @@ export default function UsersScreen() { if (createType === "client") { const freshClients = await getAllClients(); setClients(freshClients); - if (freshClients.find((c) => c.username === usernameToCreate)) { + if ( + freshClients.find( + (c) => c.username === usernameToCreate, + ) + ) { setCreateModal(false); return; } } else { const freshUsers = await getAllUsers(); setUsers(freshUsers); - if (freshUsers.find((u: any) => u.username === usernameToCreate)) { + if ( + freshUsers.find( + (u: any) => u.username === usernameToCreate, + ) + ) { setCreateModal(false); return; } @@ -698,7 +887,8 @@ export default function UsersScreen() { // Filter grid filterGrid: { flexDirection: "row", - paddingHorizontal: screenWidth < 380 ? spacing.s : spacing.m, + paddingHorizontal: + screenWidth < 380 ? spacing.s : spacing.m, paddingVertical: spacing.s, gap: screenWidth < 380 ? spacing.xs : spacing.s, }, @@ -777,6 +967,20 @@ export default function UsersScreen() { fontSize: fontSize.sm, marginTop: 2, }, + parrainRow: { + flexDirection: "row", + alignItems: "center", + gap: 4, + marginTop: 4, + }, + parrainText: { + color: colors.textMuted, + fontSize: fontSize.xs, + }, + parrainName: { + color: colors.success, + fontWeight: "600", + }, statsRow: { flexDirection: "row", flexWrap: "wrap", @@ -795,7 +999,11 @@ export default function UsersScreen() { color: colors.textWhite, textAlign: "center", }, - statLabel: { fontSize: fontSize.xs, color: colors.textMuted, textAlign: "center" }, + statLabel: { + fontSize: fontSize.xs, + color: colors.textMuted, + textAlign: "center", + }, // Non-client nonClientInfo: { @@ -940,7 +1148,9 @@ export default function UsersScreen() { {referralEnabled && ( openReferralModal(item.clientData!)} + onPress={() => + openReferralModal(item.clientData!) + } > )} - {referralEnabled && (item.clientData!.referral_balance ?? 0) > 0 && ( - handleResetReferral(item.clientData!.username)} - > - - - )} + {referralEnabled && + (item.clientData!.referral_balance ?? 0) > + 0 && ( + + handleResetReferral( + item.clientData!.username, + ) + } + > + + + )} openCancelledOrders(item.clientData!)} + onPress={() => + openCancelledOrders(item.clientData!) + } > openSanctionModal(item.clientData!)} + onPress={() => + openSanctionModal(item.clientData!) + } > openEditClient(item.clientData!)} + onPress={() => + openEditClient(item.clientData!) + } > )} + {item.role === "livreur" && ( + openLoginHistory(item.username)} + > + + + )} {(item.role === "livreur" || item.role === "cabine") && ( {item.clientData.telephone}
+ {parrainMap[item.clientData.username] ? ( + + + + Parrainé par{" "} + + { + parrainMap[ + item.clientData.username + ] + } + + + + ) : null}
- + {item.clientData.command} - Cmd + + Cmd + - {pointsEnabled && poolNames.map((name, i) => { - const key = poolKeys[i] ?? ""; - const value = key ? (item.clientData!.points_extra?.[key] ?? 0) : 0; - const color = i === 0 ? colors.success : i === 1 ? colors.info : colors.warning; - return ( - - {value} - {name} - - ); - })} + {pointsEnabled && + poolNames.map((name, i) => { + const key = poolKeys[i] ?? ""; + const value = key + ? (item.clientData!.points_extra?.[ + key + ] ?? 0) + : 0; + const color = + i === 0 + ? colors.success + : i === 1 + ? colors.info + : colors.warning; + return ( + + + {value} + + + {name} + + + ); + })} {amendeEnabled && ( {item.clientData.amende}€ - Amende + + Amende + )} {item.clientData.cancellations_count} - Annul. + + Annul. + - {referralEnabled && (item.clientData.referral_balance ?? 0) > 0 && ( - - - {(item.clientData.referral_balance ?? 0).toFixed(0)}€ - - Parrain - - )} + {referralEnabled && + (item.clientData.referral_balance ?? 0) > 0 && ( + + + {( + item.clientData + .referral_balance ?? 0 + ).toFixed(0)} + € + + + Parrain + + + )} )} @@ -1157,7 +1470,11 @@ export default function UsersScreen() { {/* Search bar */} - + {search.length > 0 && ( setSearch("")}> - + )} @@ -1379,7 +1700,13 @@ export default function UsersScreen() { value={createParrain} onChangeText={setCreateParrain} autoCapitalize="none" - icon={} + icon={ + + } /> )} @@ -1406,54 +1733,139 @@ export default function UsersScreen() { {/* Modal commandes annulées */} setCancelledModal((prev) => ({ ...prev, visible: false }))} + onClose={() => + setCancelledModal((prev) => ({ ...prev, visible: false })) + } title={`Annulations — ${cancelledModal.username}`} icon="ban-outline" iconColor={colors.danger} > {cancelledModal.loading ? ( - + ) : cancelledModal.orders.length === 0 ? ( - + Aucune commande annulée ) : ( - + {cancelledModal.orders.map((order, idx) => { const date = order.cancellation?.cancelled_at - ? new Date(order.cancellation.cancelled_at).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }) - : new Date(order.updated_at).toLocaleDateString("fr-FR", { day: "2-digit", month: "2-digit", year: "numeric" }); - const reason = order.cancel_reason || order.cancellation?.reason || ""; + ? new Date( + order.cancellation.cancelled_at, + ).toLocaleDateString("fr-FR", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }) + : new Date(order.updated_at).toLocaleDateString( + "fr-FR", + { + day: "2-digit", + month: "2-digit", + year: "numeric", + }, + ); + const reason = + order.cancel_reason || + order.cancellation?.reason || + ""; return ( - - + + Commande #{order.id} - {date} - - - - {order.items_count} article{order.items_count > 1 ? "s" : ""} + + {date} - - {Number(order.total_prix).toFixed(2)} € + + + + {order.items_count} article + {order.items_count > 1 ? "s" : ""} + + + {Number(order.total_prix).toFixed( + 2, + )}{" "} + € {reason ? ( - + Raison : {reason} ) : ( - + Aucune raison fournie )} @@ -1464,16 +1876,171 @@ export default function UsersScreen() { )} + {/* Modal historique de connexion (livreur) */} + + setLoginHistoryModal((prev) => ({ + ...prev, + visible: false, + })) + } + title={`Connexions — ${loginHistoryModal.username}`} + icon="time-outline" + iconColor={colors.accent} + > + + changeLoginHistoryMonth(-1)} + > + + + + {new Date( + loginHistoryModal.year, + loginHistoryModal.month - 1, + 1, + ) + .toLocaleDateString("fr-FR", { + month: "long", + year: "numeric", + }) + .replace(/^./, (c) => c.toUpperCase())} + + changeLoginHistoryMonth(1)} + > + + + + + {loginHistoryModal.loading ? ( + + ) : loginHistoryModal.weeks.length === 0 ? ( + + Aucune connexion ce mois-ci + + ) : ( + + {loginHistoryModal.weeks.map((week) => ( + + + Semaine {week.week} + + {week.entries.map((entry, idx) => ( + + + {new Date( + entry.created_at, + ).toLocaleDateString("fr-FR", { + weekday: "short", + day: "2-digit", + month: "2-digit", + })} + + + {new Date( + entry.created_at, + ).toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + })} + + + ))} + + ))} + + )} + + {/* Modal points & amendes */} setSanctionModal({ visible: false, client: null })} + onClose={() => + setSanctionModal({ visible: false, client: null }) + } title={`Points & amendes — ${sanctionModal.client?.username ?? ""}`} icon="wallet-outline" iconColor={colors.warning} > {/* Onglets */} - + {(["amende", "points"] as const).map((tab) => ( - + {tab === "amende" ? "Amende" : "Points"} @@ -1521,19 +2102,32 @@ export default function UsersScreen() { {sanctionTab === "amende" && ( <> {/* Valeur actuelle */} - - + + Amende actuelle - + {sanctionModal.client?.amende ?? 0} € @@ -1544,16 +2138,30 @@ export default function UsersScreen() { value={amendeAmount} onChangeText={setAmendeAmount} keyboardType="decimal-pad" - icon={} + icon={ + + } /> } + icon={ + + } /> + ); + }; return ( <> - {/* ── Top Bar ─────────────────────────────────── */} + {/* ── Top Bar ── */}
- - {shopName} -
- {/* Theme toggle */} - - - {/* Notifications */} - - - {/* Panier */}
- {/* ── Notifications modal ─────────────────────── */} + {/* ── Notifications ── */} {showNotifPanel && ( -
+
setShowNotifPanel(false)}>
e.stopPropagation()}>
Notifications -
{notifications.length === 0 ? (
- +

Aucune notification

- ) : ( - notifications.map((n, i) => ( -
- {n.message} - {formatNotifDate(n.created_at)} -
- )) - )} + ) : notifications.map((n, i) => ( +
+ {n.message} + {formatNotifDate(n.created_at)} +
+ ))}
)} - {/* ── Overlay ─────────────────────────────────── */} - {isMenuOpen &&
} - - {/* ── Sidebar ─────────────────────────────────── */} - + {/* ── Bottom nav ── */} + ); } diff --git a/frontend-prep/src/components/ProductCard.tsx b/frontend-prep/src/components/ProductCard.tsx index 707c5efc..12fdbdd8 100644 --- a/frontend-prep/src/components/ProductCard.tsx +++ b/frontend-prep/src/components/ProductCard.tsx @@ -57,7 +57,8 @@ function ProductCard({ null, ); const [showSuccess, setShowSuccess] = useState(false); - const [showVideo, setShowVideo] = useState(false); // ✨ État pour afficher/masquer la vidéo + const [showVideo, setShowVideo] = useState(false); + const [stockWarning, setStockWarning] = useState<{ wanted: number; available: number } | null>(null); const isOutOfStock = stock === 0; const isComingSoon = coming_soon === true; @@ -100,16 +101,13 @@ function ProductCard({ const selectedGrams = Number(e.target.value); setSelectedQuantity(selectedGrams); + if (stock > 0 && selectedGrams > stock) { + setStockWarning({ wanted: selectedGrams, available: stock }); + return; + } + const priceOption = prices?.find((p) => p.quantity === selectedGrams); if (priceOption) { - console.log("💾 [ProductCard] Ajout au panier:", { - id, - name, - category: normalizedCategory, - quantity: selectedGrams, - price: priceOption.price, - }); - addToCart({ product_id: id, name_product: name, @@ -237,6 +235,49 @@ function ProductCard({ )}
+ {/* Modal stock insuffisant */} + {stockWarning && + createPortal( +
setStockWarning(null)}> +
e.stopPropagation()} + > + +
⚠️
+

+ Stock insuffisant +

+

+ Vous avez sélectionné {stockWarning.wanted}g mais il ne reste que{" "} + {stockWarning.available}g disponible pour {name}. +

+ +
+
, + document.body, + )} + {/* ✨ Modal vidéo — rendu via Portal pour éviter le clipping du transform:scale sur .product-card */} {showVideo && videoUrl && diff --git a/frontend-prep/src/components/Toast.css b/frontend-prep/src/components/Toast.css index 9ac6df9c..c58dd9a1 100644 --- a/frontend-prep/src/components/Toast.css +++ b/frontend-prep/src/components/Toast.css @@ -1,132 +1,108 @@ -/* ===== TOAST PROFESSIONAL STYLES ===== */ +/* ===== TOAST MINIMAL STYLES ===== */ .toast { position: fixed; - bottom: 28px; - right: 28px; - min-width: 360px; - max-width: 450px; - padding: 14px 18px; - border-radius: 12px; - backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); + top: calc(var(--topbar-h, 60px) + 10px); + left: 50%; + transform: translate(-50%, 0); + width: min(340px, calc(100vw - 32px)); + padding: 9px 14px; + border-radius: 10px; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); border: 1px solid rgba(255, 255, 255, 0.08); - box-shadow: - 0 10px 40px rgba(0, 0, 0, 0.2), - inset 0 1px 1px rgba(255, 255, 255, 0.1); - animation: toastSlideIn 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); - z-index: 9999; + border-left-width: 3px; + background: var(--surface, #111115); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18); + animation: toastSlideDown 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94); + z-index: 950; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', sans-serif; } -@keyframes toastSlideIn { +@keyframes toastSlideDown { from { - transform: translateX(480px); + transform: translate(-50%, -12px); opacity: 0; } to { - transform: translateX(0); + transform: translate(-50%, 0); opacity: 1; } } /* SUCCESS - VIOLET */ .toast-success { - background: linear-gradient(135deg, rgba(124, 58, 237, 0.1) 0%, rgba(124, 58, 237, 0.05) 100%); - border-color: rgba(124, 58, 237, 0.3); + border-left-color: #7c3aed; color: #7c3aed; } .toast-success .toast-icon { color: #7c3aed; - background: rgba(124, 58, 237, 0.15); - border: 1px solid rgba(124, 58, 237, 0.4); } .toast-success .toast-progress { - background: linear-gradient(90deg, #7c3aed, #a78bfa, transparent); + background: #7c3aed; } /* ERROR */ .toast-error { - background: linear-gradient(135deg, rgba(239, 68, 68, 0.08) 0%, rgba(220, 38, 38, 0.04) 100%); - border-color: rgba(239, 68, 68, 0.2); + border-left-color: #dc2626; color: #dc2626; } .toast-error .toast-icon { color: #dc2626; - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.25); } .toast-error .toast-progress { - background: linear-gradient(90deg, #dc2626, #b91c1c, transparent); + background: #dc2626; } /* WARNING */ .toast-warning { - background: linear-gradient(135deg, rgba(245, 158, 11, 0.08) 0%, rgba(217, 119, 6, 0.04) 100%); - border-color: rgba(245, 158, 11, 0.2); + border-left-color: #d97706; color: #d97706; } .toast-warning .toast-icon { color: #d97706; - background: rgba(245, 158, 11, 0.1); - border: 1px solid rgba(245, 158, 11, 0.25); } .toast-warning .toast-progress { - background: linear-gradient(90deg, #d97706, #b45309, transparent); + background: #d97706; } /* INFO */ .toast-info { - background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(37, 99, 235, 0.04) 100%); - border-color: rgba(59, 130, 246, 0.2); + border-left-color: #2563eb; color: #2563eb; } .toast-info .toast-icon { color: #2563eb; - background: rgba(59, 130, 246, 0.1); - border: 1px solid rgba(59, 130, 246, 0.25); } .toast-info .toast-progress { - background: linear-gradient(90deg, #2563eb, #1d4ed8, transparent); + background: #2563eb; } .toast-content { display: flex; align-items: center; - gap: 12px; + gap: 8px; } .toast-icon { - font-size: 18px; + font-size: 13px; font-weight: 700; - min-width: 32px; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 8px; flex-shrink: 0; - transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); -} - -.toast:hover .toast-icon { - transform: scale(1.08); } .toast-message { - font-size: 14px; + font-size: 12.5px; font-weight: 500; - line-height: 1.5; - flex: 1; + line-height: 1.4; + flex: 1 1 auto; letter-spacing: 0.1px; color: var(--text); } @@ -135,7 +111,8 @@ position: absolute; bottom: 0; left: 0; - height: 2px; + height: 1.5px; + opacity: 0.6; animation: toastProgressBar linear forwards; } @@ -148,23 +125,3 @@ } } -/* Responsive */ -@media (max-width: 640px) { - .toast { - bottom: 20px; - right: 20px; - left: 20px; - min-width: auto; - max-width: none; - } -} - -@media (hover: none) { - .toast { - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); - } - - .toast:hover .toast-icon { - transform: none; - } -} \ No newline at end of file diff --git a/frontend-prep/src/context/ThemeContext.tsx b/frontend-prep/src/context/ThemeContext.tsx index f11edfae..aa3eb54a 100644 --- a/frontend-prep/src/context/ThemeContext.tsx +++ b/frontend-prep/src/context/ThemeContext.tsx @@ -1,5 +1,6 @@ -import { createContext, useContext, useEffect, useState } from "react"; +import { createContext, useContext, useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; +import { getPublicSettings } from "../api/api"; type Theme = "dark" | "light"; @@ -13,18 +14,103 @@ const ThemeContext = createContext({ toggleTheme: () => {}, }); +const LS_COLORS_KEY = "client_colors"; + +function hexToRgba(hex: string, alpha: number): string { + const h = hex.replace("#", ""); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +type ColorSettings = { + client_color_primary: string; + client_color_secondary: string; + client_color_success: string; + client_color_danger: string; + client_color_warning: string; + client_title_gradient_from: string; + client_title_gradient_to: string; +}; + +function applyClientColors(s: ColorSettings) { + const root = document.documentElement; + + if (s.client_color_primary) { + const p = s.client_color_primary; + root.style.setProperty("--primary", p); + root.style.setProperty("--primary-soft", hexToRgba(p, 0.12)); + root.style.setProperty("--primary-glow", hexToRgba(p, 0.25)); + } + if (s.client_color_secondary) { + root.style.setProperty("--cyan", s.client_color_secondary); + root.style.setProperty("--cyan-soft", hexToRgba(s.client_color_secondary, 0.1)); + } + if (s.client_color_danger) { + root.style.setProperty("--red", s.client_color_danger); + } + if (s.client_title_gradient_from) { + root.style.setProperty("--title-gradient-from", s.client_title_gradient_from); + } + if (s.client_title_gradient_to) { + root.style.setProperty("--title-gradient-to", s.client_title_gradient_to); + } +} + +function saveColorsToStorage(s: ColorSettings) { + try { + localStorage.setItem(LS_COLORS_KEY, JSON.stringify(s)); + } catch { /* ignore */ } +} + +function loadColorsFromStorage(): ColorSettings | null { + try { + const raw = localStorage.getItem(LS_COLORS_KEY); + if (raw) return JSON.parse(raw) as ColorSettings; + } catch { /* ignore */ } + return null; +} + export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setTheme] = useState(() => { const stored = localStorage.getItem("theme"); if (stored === "light" || stored === "dark") return stored; return "dark"; }); + const lastSettings = useRef(null); + + // Appliquer les couleurs sauvegardées immédiatement (avant l'appel API) + useEffect(() => { + const cached = loadColorsFromStorage(); + if (cached) { + lastSettings.current = cached; + applyClientColors(cached); + } + }, []); useEffect(() => { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); }, [theme]); + useEffect(() => { + const fetchColors = async () => { + try { + const settings = await getPublicSettings(); + lastSettings.current = settings; + saveColorsToStorage(settings); + applyClientColors(settings); + } catch { /* ignore */ } + }; + fetchColors(); + const onVisibility = () => { + if (document.visibilityState === "visible") fetchColors(); + }; + document.addEventListener("visibilitychange", onVisibility); + return () => document.removeEventListener("visibilitychange", onVisibility); + }, []); + const toggleTheme = () => { setTheme((prev) => (prev === "dark" ? "light" : "dark")); }; diff --git a/frontend-prep/src/index.css b/frontend-prep/src/index.css index 662235f7..639373cd 100644 --- a/frontend-prep/src/index.css +++ b/frontend-prep/src/index.css @@ -1,126 +1,128 @@ /* Reset et optimisation mobile */ * { - margin: 0; - padding: 0; - box-sizing: border-box; - -webkit-tap-highlight-color: transparent; - + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; } :root { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; - line-height: 1.5; - font-weight: 400; - color-scheme: dark; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - font-size: 16px; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", sans-serif; + line-height: 1.5; + font-weight: 400; + color-scheme: dark; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-size: 16px; - --bg: #09090b; - --surface: #111115; - --surface-2: #1c1c22; - --border: rgba(255, 255, 255, 0.07); - --primary: #8b5cf6; - --primary-soft: rgba(139, 92, 246, 0.12); - --primary-glow: rgba(139, 92, 246, 0.25); - --cyan: #22d3ee; - --cyan-soft: rgba(34, 211, 238, 0.1); - --red: #ef4444; - --text: #f4f4f5; - --text-muted: #71717a; - --radius: 10px; - --transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1); + --bg: #09090b; + --surface: #111115; + --surface-2: #1c1c22; + --border: rgba(255, 255, 255, 0.07); + --primary: #8b5cf6; + --primary-soft: rgba(139, 92, 246, 0.12); + --primary-glow: rgba(139, 92, 246, 0.25); + --cyan: #22d3ee; + --cyan-soft: rgba(34, 211, 238, 0.1); + --red: #ef4444; + --text: #f4f4f5; + --text-muted: #71717a; + --radius: 10px; + --transition: 0.22s cubic-bezier(0.4, 0, 0.2, 1); } [data-theme="light"] { - color-scheme: light; - --bg: #f4f4f8; - --surface: #ffffff; - --surface-2: #ebebf2; - --border: rgba(0, 0, 0, 0.08); - --primary: #7c3aed; - --primary-soft: rgba(124, 58, 237, 0.08); - --primary-glow: rgba(124, 58, 237, 0.18); - --cyan: #0891b2; - --cyan-soft: rgba(8, 145, 178, 0.1); - --red: #dc2626; - --text: #18181b; - --text-muted: #52525b; + color-scheme: light; + --bg: #f4f4f8; + --surface: #ffffff; + --surface-2: #ebebf2; + --border: rgba(0, 0, 0, 0.08); + --primary: #7c3aed; + --primary-soft: rgba(124, 58, 237, 0.08); + --primary-glow: rgba(124, 58, 237, 0.18); + --cyan: #0891b2; + --cyan-soft: rgba(8, 145, 178, 0.1); + --red: #dc2626; + --text: #18181b; + --text-muted: #52525b; } -html, body { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - overflow-x: hidden; - /* Désactiver le scroll bounce sur iOS */ - overscroll-behavior: none; - -webkit-overflow-scrolling: touch; +html, +body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow-x: hidden; + /* Désactiver le scroll bounce sur iOS */ + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; } body { - position: fixed; - width: 100%; - height: 100%; - background-color: var(--bg); - color: var(--text); + position: fixed; + width: 100%; + height: 100%; + background-color: var(--bg); + color: var(--text); } #root { - width: 100%; - height: 100%; - overflow: auto; + width: 100%; + height: 100%; + overflow: auto; } a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; - -webkit-tap-highlight-color: transparent; + font-weight: 500; + color: #646cff; + text-decoration: inherit; + -webkit-tap-highlight-color: transparent; } a:active { - opacity: 0.7; + opacity: 0.7; } h1 { - font-size: clamp(1.8rem, 5vw, 3.2rem); - line-height: 1.1; + font-size: clamp(1.8rem, 5vw, 3.2rem); + line-height: 1.1; } h2 { - font-size: clamp(1.5rem, 4vw, 2.5rem); + font-size: clamp(1.5rem, 4vw, 2.5rem); } p { - font-size: clamp(0.9rem, 2.5vw, 1.1rem); + font-size: clamp(0.9rem, 2.5vw, 1.1rem); } button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.8em 1.5em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: var(--surface); - cursor: pointer; - transition: all 0.2s; - -webkit-tap-highlight-color: transparent; - touch-action: manipulation; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.8em 1.5em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: var(--surface); + cursor: pointer; + transition: all 0.2s; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; } button:active { - transform: scale(0.95); - opacity: 0.8; + transform: scale(0.95); + opacity: 0.8; } /* Désactiver le hover sur mobile */ @media (hover: none) { - button:hover { - border-color: transparent; - } + button:hover { + border-color: transparent; + } } diff --git a/frontend-prep/src/pages/Home.css b/frontend-prep/src/pages/Home.css index e82e24b5..0aadc5ab 100644 --- a/frontend-prep/src/pages/Home.css +++ b/frontend-prep/src/pages/Home.css @@ -1,233 +1,240 @@ /* Reset et optimisation mobile */ * { - margin: 0; - padding: 0; - box-sizing: border-box; - -webkit-tap-highlight-color: transparent; + margin: 0; + padding: 0; + box-sizing: border-box; + -webkit-tap-highlight-color: transparent; } :root { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; - line-height: 1.5; - font-weight: 400; - color: rgba(255, 255, 255, 0.87); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - /* Empêcher le zoom sur les inputs pour iOS */ - font-size: 16px; - /* Couleurs néon violet */ - --neon-violet: rgba(138, 43, 226, 1); /* Blueviolet */ - --neon-violet-light: rgba(186, 85, 211, 1); /* Medium Orchid */ - --neon-violet-glow: rgba(138, 43, 226, 0.6); + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", + "Ubuntu", "Cantarell", sans-serif; + line-height: 1.5; + font-weight: 400; + color: rgba(255, 255, 255, 0.87); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + /* Empêcher le zoom sur les inputs pour iOS */ + font-size: 16px; + /* Couleurs néon violet */ + --neon-violet: rgba(138, 43, 226, 1); /* Blueviolet */ + --neon-violet-light: rgba(186, 85, 211, 1); /* Medium Orchid */ + --neon-violet-glow: rgba(138, 43, 226, 0.6); } -html, body { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - overflow-x: hidden; - /* Désactiver le scroll bounce sur iOS */ - overscroll-behavior: none; - -webkit-overflow-scrolling: touch; - /* Ne pas forcer de background pour permettre aux pages d'avoir leur propre style */ - background-color: transparent !important; +html, +body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + overflow-x: hidden; + /* Désactiver le scroll bounce sur iOS */ + overscroll-behavior: none; + -webkit-overflow-scrolling: touch; } body { - position: fixed; - width: 100%; - height: 100%; + position: fixed; + width: 100%; + height: 100%; } /* Effet de hover sur toute la page - Curseur personnalisé violet */ body::before { - content: ''; - position: fixed; - width: 40px; - height: 40px; - border: 2px solid var(--neon-violet-glow); - border-radius: 50%; - pointer-events: none; - z-index: 9999; - transition: transform 0.2s ease, opacity 0.2s ease; - opacity: 0; - box-shadow: 0 0 10px var(--neon-violet-glow); + content: ""; + position: fixed; + width: 40px; + height: 40px; + border: 2px solid var(--neon-violet-glow); + border-radius: 50%; + pointer-events: none; + z-index: 9999; + transition: + transform 0.2s ease, + opacity 0.2s ease; + opacity: 0; + box-shadow: 0 0 10px var(--neon-violet-glow); } body:hover::before { - opacity: 1; + opacity: 1; } /* Effet de gradient violet qui suit le curseur */ body::after { - content: ''; - position: fixed; - width: 300px; - height: 300px; - background: radial-gradient(circle, rgba(138, 43, 226, 0.15) 0%, transparent 70%); - pointer-events: none; - z-index: 1; - opacity: 0; - transition: opacity 0.3s ease; + content: ""; + position: fixed; + width: 300px; + height: 300px; + background: radial-gradient( + circle, + rgba(138, 43, 226, 0.15) 0%, + transparent 70% + ); + pointer-events: none; + z-index: 1; + opacity: 0; + transition: opacity 0.3s ease; } @media (hover: hover) { - body:hover::after { - opacity: 1; - } + body:hover::after { + opacity: 1; + } } #root { - width: 100%; - height: 100%; - overflow: auto; - /* Ne pas forcer de background sur root non plus */ - background-color: transparent !important; - position: relative; + width: 100%; + height: 100%; + overflow: auto; + /* Ne pas forcer de background sur root non plus */ + background-color: transparent !important; + position: relative; } /* Effet de particules/grille au hover avec teinte violette */ #root::before { - content: ''; - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-image: - linear-gradient(rgba(186, 85, 211, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(186, 85, 211, 0.05) 1px, transparent 1px); - background-size: 50px 50px; - pointer-events: none; - opacity: 0; - transition: opacity 0.5s ease; - z-index: 0; + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-image: + linear-gradient(rgba(186, 85, 211, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(186, 85, 211, 0.05) 1px, transparent 1px); + background-size: 50px 50px; + pointer-events: none; + opacity: 0; + transition: opacity 0.5s ease; + z-index: 0; } @media (hover: hover) { - #root:hover::before { - opacity: 1; - } + #root:hover::before { + opacity: 1; + } } a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; - -webkit-tap-highlight-color: transparent; + font-weight: 500; + color: #646cff; + text-decoration: inherit; + -webkit-tap-highlight-color: transparent; } a:active { - opacity: 0.7; + opacity: 0.7; } h1 { - font-size: clamp(1.8rem, 5vw, 3.2rem); - line-height: 1.1; + font-size: clamp(1.8rem, 5vw, 3.2rem); + line-height: 1.1; } h2 { - font-size: clamp(1.5rem, 4vw, 2.5rem); + font-size: clamp(1.5rem, 4vw, 2.5rem); } p { - font-size: clamp(0.9rem, 2.5vw, 1.1rem); + font-size: clamp(0.9rem, 2.5vw, 1.1rem); } button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.8em 1.5em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: var(--surface); - cursor: pointer; - transition: all 0.2s; - -webkit-tap-highlight-color: transparent; - touch-action: manipulation; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.8em 1.5em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: var(--surface); + cursor: pointer; + transition: all 0.2s; + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; } button:active { - transform: scale(0.95); - opacity: 0.8; + transform: scale(0.95); + opacity: 0.8; } /* Désactiver le hover sur mobile */ @media (hover: none) { - button:hover { - border-color: transparent; - } - - /* Désactiver les effets de hover sur mobile */ - body::before, - body::after, - #root::before { - display: none; - } + button:hover { + border-color: transparent; + } + + /* Désactiver les effets de hover sur mobile */ + body::before, + body::after, + #root::before { + display: none; + } } .home-container2 { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - min-height: calc(100vh - 60px); - padding: 2rem; - gap: 2rem; - position: relative; - z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: calc(100vh - 60px); + padding: 2rem; + gap: 2rem; + position: relative; + z-index: 2; } /* Container plein écran pour la vidéo */ .home-container-fullscreen { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - overflow: hidden; - cursor: pointer; - z-index: 1000; - background-color: #000; + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + overflow: hidden; + cursor: pointer; + z-index: 1000; + background-color: #000; } /* Vidéo en plein écran */ .home-video-fullscreen { - position: absolute; - top: 50%; - left: 50%; - min-width: 100%; - min-height: 100%; - width: auto; - height: auto; - transform: translate(-50%, -50%); - object-fit: cover; - z-index: 0; + position: absolute; + top: 50%; + left: 50%; + min-width: 100%; + min-height: 100%; + width: auto; + height: auto; + transform: translate(-50%, -50%); + object-fit: cover; + z-index: 0; } /* Effet au hover (desktop uniquement) */ @media (hover: hover) { - .home-container-fullscreen:hover { - opacity: 0.95; - } - - .home-container-fullscreen:hover .home-video-fullscreen { - filter: brightness(1.1); - } + .home-container-fullscreen:hover { + opacity: 0.95; + } + + .home-container-fullscreen:hover .home-video-fullscreen { + filter: brightness(1.1); + } } /* Effet au clic */ .home-container-fullscreen:active { - opacity: 0.9; + opacity: 0.9; } /* Optimisation mobile */ @media (max-width: 768px) { - .home-video-fullscreen { - object-fit: cover; - } -} \ No newline at end of file + .home-video-fullscreen { + object-fit: cover; + } +} diff --git a/frontend-prep/src/pages/User/Accueil.tsx b/frontend-prep/src/pages/User/Accueil.tsx index 982e35f8..9d95e464 100644 --- a/frontend-prep/src/pages/User/Accueil.tsx +++ b/frontend-prep/src/pages/User/Accueil.tsx @@ -27,7 +27,13 @@ function UserAccueil() { const [error, setError] = useState(null); useEffect(() => { - getCategories().then(setCategories); + getCategories().then((cats) => { + const sorted = [...cats].sort((a, b) => { + if (a.is_coming_soon === b.is_coming_soon) return 0; + return a.is_coming_soon ? 1 : -1; + }); + setCategories(sorted); + }); }, []); useEffect(() => { @@ -38,15 +44,11 @@ function UserAccueil() { return; } loadProducts(); - const interval = setInterval(() => loadProducts(true), 30_000); - return () => clearInterval(interval); }, [selectedCategory, categories]); // eslint-disable-line react-hooks/exhaustive-deps - const loadProducts = async (silent = false) => { - if (!silent) { - setLoading(true); - setError(null); - } + const loadProducts = async () => { + setLoading(true); + setError(null); try { let response; @@ -58,25 +60,14 @@ function UserAccueil() { } if (response.success && response.data) { - if (silent) { - setProducts((prev) => - prev.map((p) => { - const updated = response.data!.find((r: Product) => r.id === p.id); - return updated ? { ...p, stock: updated.stock } : p; - }) - ); - } else { - setProducts(response.data); - } - } else if (!silent) { + setProducts(response.data); + } else { setProducts([]); } } catch (err: unknown) { - if (!silent) { - setError(err instanceof Error ? err.message : "Erreur lors du chargement des produits"); - } + setError(err instanceof Error ? err.message : "Erreur lors du chargement des produits"); } finally { - if (!silent) setLoading(false); + setLoading(false); } }; @@ -188,9 +179,9 @@ function UserAccueil() {
{isSelectedComingSoon ? ( -
- Prochainement -

+

+ Prochainement +

Les produits de cette catégorie arrivent bientôt !

@@ -201,7 +192,7 @@ function UserAccueil() { ) : error ? (

{error}

- +
) : products.length === 0 ? (
diff --git a/frontend-prep/src/pages/User/Cart.css b/frontend-prep/src/pages/User/Cart.css index dcbbbb5a..4eba2adb 100644 --- a/frontend-prep/src/pages/User/Cart.css +++ b/frontend-prep/src/pages/User/Cart.css @@ -8,7 +8,9 @@ padding-top: 60px; max-width: 640px; margin: 0 auto; - padding-bottom: 140px; + padding-bottom: calc( + var(--bottomnav-h, 66px) + var(--bottomnav-offset, 15px) + 150px + ); background: var(--bg); } @@ -22,6 +24,7 @@ align-items: center; padding: 1rem 1.25rem; border-bottom: 1px solid var(--border); + margin-top: -70px; } .cart-header-left { @@ -177,7 +180,9 @@ align-items: center; justify-content: center; opacity: 0.7; - transition: opacity 0.15s, background 0.15s; + transition: + opacity 0.15s, + background 0.15s; flex-shrink: 0; } @@ -197,15 +202,22 @@ .cart-footer { position: fixed; - bottom: 0; + /* Remonté au-dessus de la bottom-nav flottante (qui est en + bottom: var(--bottomnav-offset) avec z-index: 900), pour que + le bouton "Commander" ne soit jamais masqué par le menu. */ + bottom: calc( + var(--bottomnav-h, 66px) + var(--bottomnav-offset, 15px) + 10px + ); left: 50%; transform: translateX(-50%); - width: 100%; + width: calc(100% - 1.5rem); max-width: 640px; background: var(--surface); - border-top: 1px solid var(--border); - padding: 1rem 1.25rem 1.25rem; - z-index: 100; + border: 1px solid var(--border); + border-radius: 16px; + padding: 1rem 1.25rem 1.1rem; + z-index: 850; + box-shadow: 0 8px 28px rgba(0, 0, 0, 0.35); } .cart-footer-total { @@ -236,7 +248,9 @@ font-size: 1rem; font-weight: 700; cursor: pointer; - transition: opacity 0.2s, transform 0.15s; + transition: + opacity 0.2s, + transform 0.15s; letter-spacing: 0.3px; } @@ -296,7 +310,9 @@ font-size: 0.95rem; font-weight: 600; cursor: pointer; - transition: opacity 0.2s, transform 0.15s; + transition: + opacity 0.2s, + transform 0.15s; } .continue-shopping:hover { @@ -320,8 +336,13 @@ } @keyframes pulse { - 0%, 100% { opacity: 0.5; } - 50% { opacity: 1; } + 0%, + 100% { + opacity: 0.5; + } + 50% { + opacity: 1; + } } /* ============================================ @@ -342,8 +363,12 @@ } @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } .modal-content2 { @@ -357,8 +382,14 @@ } @keyframes slideUp { - from { opacity: 0; transform: translateY(16px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(16px); + } + to { + opacity: 1; + transform: translateY(0); + } } .modal-icon-container { @@ -404,7 +435,9 @@ align-items: center; justify-content: center; gap: 0.4rem; - transition: opacity 0.15s, transform 0.15s; + transition: + opacity 0.15s, + transform 0.15s; } .modal-button:hover:not(:disabled) { @@ -473,7 +506,9 @@ cursor: pointer; z-index: 10; font-size: 1.1rem; - transition: background 0.2s, transform 0.2s; + transition: + background 0.2s, + transform 0.2s; } .video-close-btn:hover { diff --git a/frontend-prep/src/pages/User/Checkout.tsx b/frontend-prep/src/pages/User/Checkout.tsx index 5b00ccf2..80c44803 100644 --- a/frontend-prep/src/pages/User/Checkout.tsx +++ b/frontend-prep/src/pages/User/Checkout.tsx @@ -48,6 +48,10 @@ function Checkout() { const [showZoneModal, setShowZoneModal] = useState(false); const [zoneErrorMsg, setZoneErrorMsg] = useState(''); + // État pour le modal adresse invalide (correction suggérée) + const [showInvalidAddressModal, setShowInvalidAddressModal] = useState(false); + const [suggestedAddress, setSuggestedAddress] = useState(''); + // Informations personnelles const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); @@ -315,6 +319,12 @@ function Checkout() { return; } + if (!response.success && response.invalid_address && response.suggested_address) { + setSuggestedAddress(response.suggested_address); + setShowInvalidAddressModal(true); + return; + } + if (response.success && response.command_id) { const { command_id, assigned_to, queue_info, delivery_address } = response; @@ -725,6 +735,46 @@ function Checkout() {
)} + {/* ============================================ */} + {/* MODAL ADRESSE INVALIDE (CORRECTION SUGGÉRÉE) */} + {/* ============================================ */} + {showInvalidAddressModal && ( +
setShowInvalidAddressModal(false)}> +
e.stopPropagation()}> +
+ +

Adresse invalide

+
+
+
+
+

+ L'adresse saisie n'est pas reconnue. Voulez-vous utiliser l'adresse correcte suggérée ? +

+

+ {suggestedAddress} +

+
+
+
+
+ + +
+
+
+ )} + {/* ============================================ */} {/* MODAL DE CONFIRMATION STYLISÉ */} {/* ============================================ */} diff --git a/frontend-prep/src/pages/User/ConsultationHistorique.css b/frontend-prep/src/pages/User/ConsultationHistorique.css index 49cd40c6..d09ff297 100644 --- a/frontend-prep/src/pages/User/ConsultationHistorique.css +++ b/frontend-prep/src/pages/User/ConsultationHistorique.css @@ -20,6 +20,7 @@ font-weight: 700; color: var(--text); margin: 0 0 1.25rem 0; + margin-top: -70px; } /* ============================================ @@ -47,7 +48,10 @@ align-items: center; text-align: center; gap: 0.35rem; - transition: transform 0.2s, border-color 0.2s, box-shadow 0.2s; + transition: + transform 0.2s, + border-color 0.2s, + box-shadow 0.2s; cursor: default; } @@ -79,18 +83,32 @@ margin-bottom: 0.25rem; } -.stat-icon.icon-total-orders { background: linear-gradient(135deg, #7c3aed, #6d28d9); } -.stat-icon.icon-total { background: linear-gradient(135deg, #f59e0b, #d97706); } -.stat-icon.icon-penalty-ok { background: rgba(100, 116, 139, 0.3); color: var(--text-muted); } -.stat-icon.icon-penalty-warning { background: linear-gradient(135deg, #f59e0b, #d97706); } +.stat-icon.icon-total-orders { + background: linear-gradient(135deg, #7c3aed, #6d28d9); +} +.stat-icon.icon-total { + background: linear-gradient(135deg, #f59e0b, #d97706); +} +.stat-icon.icon-penalty-ok { + background: rgba(100, 116, 139, 0.3); + color: var(--text-muted); +} +.stat-icon.icon-penalty-warning { + background: linear-gradient(135deg, #f59e0b, #d97706); +} .stat-icon.icon-penalty-critical { background: linear-gradient(135deg, #ef4444, #dc2626); animation: pulse-danger 2s ease-in-out infinite; } @keyframes pulse-danger { - 0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); } - 50% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); } + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); + } + 50% { + box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); + } } .stat-value { @@ -101,8 +119,12 @@ line-height: 1; } -.stat-value.value-danger { color: #ef4444; } -.stat-value.value-warning { color: #f59e0b; } +.stat-value.value-danger { + color: #ef4444; +} +.stat-value.value-warning { + color: #f59e0b; +} .stat-label { color: var(--text-muted); @@ -125,7 +147,9 @@ padding: 0.875rem 1rem; margin-bottom: 1.25rem; cursor: pointer; - transition: background 0.2s, border-color 0.2s; + transition: + background 0.2s, + border-color 0.2s; } .referral-btn-row:hover { @@ -328,7 +352,10 @@ border-radius: 12px; padding: 1rem 1.125rem; cursor: pointer; - transition: transform 0.15s, border-color 0.15s, box-shadow 0.15s; + transition: + transform 0.15s, + border-color 0.15s, + box-shadow 0.15s; } .order-card:hover { @@ -449,7 +476,9 @@ } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .loading-text { @@ -521,7 +550,9 @@ font-size: 0.95rem; font-weight: 600; cursor: pointer; - transition: opacity 0.2s, transform 0.2s; + transition: + opacity 0.2s, + transform 0.2s; } .browse-button:hover { @@ -529,6 +560,219 @@ transform: translateY(-1px); } +/* ============================================ + MODAL — CHOIX DU PRODUIT RÉCOMPENSE + ============================================ */ + +.reward-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; + z-index: 1000; + animation: fadeIn 0.2s ease; +} + +.reward-modal { + background: var(--surface); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 16px; + padding: 1.5rem; + max-width: 420px; + width: 100%; + max-height: 85vh; + overflow-y: auto; + animation: slideUp 0.25s ease; +} + +.reward-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.4rem; +} + +.reward-modal-header-title { + display: flex; + align-items: center; + gap: 0.625rem; +} + +.reward-modal-icon { + color: #f59e0b; + font-size: 1.1rem; +} + +.reward-modal-header h2 { + color: var(--text); + font-size: 1.1rem; + font-weight: 700; + margin: 0; +} + +.reward-modal-close { + background: transparent; + border: 1px solid var(--border); + border-radius: 8px; + width: 32px; + height: 32px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + cursor: pointer; + transition: all 0.15s; +} + +.reward-modal-close:hover { + background: var(--surface-2); + color: var(--text); +} + +.reward-modal-subtitle { + color: var(--text-muted); + font-size: 0.85rem; + margin: 0 0 1.1rem 0; +} + +.reward-modal-products { + display: flex; + flex-direction: column; + gap: 0.625rem; + margin-bottom: 1.25rem; +} + +.reward-product-card { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + background: rgba(245, 158, 11, 0.05); + border: 1.5px solid rgba(245, 158, 11, 0.2); + border-radius: 10px; + padding: 0.75rem 0.875rem; + cursor: pointer; + transition: all 0.15s; + text-align: left; + font-family: inherit; +} + +.reward-product-card:hover { + border-color: rgba(245, 158, 11, 0.5); + background: rgba(245, 158, 11, 0.09); +} + +.reward-product-card.selected { + border-color: #f59e0b; + background: rgba(245, 158, 11, 0.14); +} + +.reward-product-radio { + width: 22px; + height: 22px; + border-radius: 50%; + border: 2px solid rgba(245, 158, 11, 0.5); + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + color: #f59e0b; + font-size: 0.7rem; + transition: all 0.15s; +} + +.reward-product-card.selected .reward-product-radio { + background: #f59e0b; + border-color: #f59e0b; + color: #fff; +} + +.reward-product-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.15rem; + min-width: 0; +} + +.reward-product-name { + color: var(--text); + font-size: 0.92rem; + font-weight: 600; +} + +.reward-product-meta { + color: var(--text-muted); + font-size: 0.78rem; +} + +.reward-product-free { + flex-shrink: 0; + color: #10b981; + font-size: 0.78rem; + font-weight: 700; + background: rgba(16, 185, 129, 0.12); + border-radius: 6px; + padding: 3px 8px; +} + +.reward-modal-actions { + display: flex; + gap: 0.75rem; +} + +.reward-modal-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem; + border-radius: 10px; + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; + border: none; + transition: + opacity 0.15s, + transform 0.15s; + font-family: inherit; +} + +.reward-modal-btn:hover:not(:disabled) { + opacity: 0.88; + transform: translateY(-1px); +} + +.reward-modal-btn-cancel { + background: transparent; + color: var(--text-muted); + border: 1px solid var(--border); +} + +.reward-modal-btn-confirm { + background: linear-gradient(135deg, #f59e0b, #d97706); + color: #fff; +} + +.reward-modal-btn-confirm:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 480px) { + .reward-modal { + padding: 1.25rem; + border-radius: 14px; + } +} + /* ============================================ RESPONSIVE ============================================ */ diff --git a/frontend-prep/src/pages/User/ConsultationHistorique.tsx b/frontend-prep/src/pages/User/ConsultationHistorique.tsx index 3d0b3559..cce6b954 100644 --- a/frontend-prep/src/pages/User/ConsultationHistorique.tsx +++ b/frontend-prep/src/pages/User/ConsultationHistorique.tsx @@ -13,7 +13,12 @@ import { getMyPointsRewards, claimMyReward, } from "../../api/api"; -import type { PublicSettings, PointsPoolInfo, PointsRewardConfig, RewardItemConfig } from "../../api/api"; +import type { + PublicSettings, + PointsPoolInfo, + PointsRewardConfig, + RewardItemConfig, +} from "../../api/api"; import type { CompletedOrder, ClientStats, @@ -38,6 +43,8 @@ import { faChevronRight, faCheckCircle, faHistory, + faTimes, + faCheck, } from "@fortawesome/free-solid-svg-icons"; function ConsultationHistorique() { @@ -60,6 +67,13 @@ function ConsultationHistorique() { shop_name: "Milieu-Nantais", two_fa_enabled: false, contact_telegram: "", + client_color_primary: "", + client_color_secondary: "", + client_color_success: "", + client_color_danger: "", + client_color_warning: "", + client_title_gradient_from: "", + client_title_gradient_to: "", }); const [referralBalance, setReferralBalance] = useState(0); const [pointsRewards, setPointsRewards] = useState<{ @@ -68,10 +82,21 @@ function ConsultationHistorique() { reward: PointsRewardConfig | null; } | null>(null); const [claimingPool, setClaimingPool] = useState(null); - const [claimFeedback, setClaimFeedback] = useState<{ pool: string; type: "success" | "error"; text: string } | null>(null); + const [claimFeedback, setClaimFeedback] = useState<{ + pool: string; + type: "success" | "error"; + text: string; + } | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(""); + // ── Modal de sélection du produit récompense ── + const [rewardModalPool, setRewardModalPool] = + useState(null); + const [selectedProductId, setSelectedProductId] = useState( + null, + ); + useEffect(() => { if (!isUserAuthenticated()) navigate("/login/client", { replace: true }); @@ -152,37 +177,51 @@ function ConsultationHistorique() { }); }; - if (isLoading) { - return ( - <> - -
-
-
-
-
-

- Chargement de l'historique... -

-
-
- - ); - } + // Ouvre la modal de choix si plusieurs produits sont éligibles, + // sinon réclame directement (1 seul produit configuré ou aucun item). + const openRewardModal = (pool: PointsPoolInfo) => { + const items = pool.eligible_reward_items ?? []; + if (items.length > 1) { + setSelectedProductId(items[0]?.product_id ?? null); + setRewardModalPool(pool); + } else { + handleClaim(pool.key); + } + }; - const handleClaim = async (poolKey: string) => { + const closeRewardModal = () => { + setRewardModalPool(null); + setSelectedProductId(null); + }; + + const confirmRewardChoice = async () => { + if (!rewardModalPool) return; + const poolKey = rewardModalPool.key; + const productId = selectedProductId ?? undefined; + closeRewardModal(); + await handleClaim(poolKey, productId); + }; + + const handleClaim = async (poolKey: string, productId?: number) => { setClaimingPool(poolKey); setClaimFeedback(null); - const res = await claimMyReward(poolKey); + const res = await claimMyReward(poolKey, productId); setClaimingPool(null); if (res.success) { - const text = res.product_added && res.product_name - ? `🎁 ${res.product_name} ajouté à votre panier ! Commandez au moins un produit pour en profiter.` - : res.description || "Récompense réclamée !"; + const text = + res.product_added && res.product_names?.length + ? `🎁 ${res.product_names.join(", ")} ajouté${res.product_names.length > 1 ? "s" : ""} à votre panier ! Commandez au moins un produit pour en profiter.` + : res.description || "Récompense réclamée !"; setClaimFeedback({ pool: poolKey, type: "success", text }); - getMyPointsRewards().then((r) => { if (r.success) setPointsRewards(r); }); + getMyPointsRewards().then((r) => { + if (r.success) setPointsRewards(r); + }); } else { - setClaimFeedback({ pool: poolKey, type: "error", text: res.error || "Erreur" }); + setClaimFeedback({ + pool: poolKey, + type: "error", + text: res.error || "Erreur", + }); } }; @@ -202,6 +241,24 @@ function ConsultationHistorique() { "#7c3aed", ]; + if (isLoading) { + return ( + <> + +
+
+
+
+
+

+ Chargement de l'historique... +

+
+
+ + ); + } + return ( <> @@ -298,81 +355,178 @@ function ConsultationHistorique() {
{/* Section récompenses par palier */} - {pointsRewards?.enabled && pointsRewards.reward && pointsRewards.pools.length > 0 && ( -
-

- - Récompenses -

- {pointsRewards.reward.description && ( -

{pointsRewards.reward.description}

- )} - {(pointsRewards.reward.reward_items ?? []).filter((it: RewardItemConfig) => it.product_name).length > 0 && ( -
- {(pointsRewards.reward.reward_items ?? []).map((it: RewardItemConfig, idx: number) => ( - - - {it.product_name}{it.quantity > 0 && it.quantity !== 1 ? ` ×${it.quantity}` : ""}{it.price > 0 ? ` — ${it.price}€` : ""} - - ))} -
- )} -
- {pointsRewards.pools.map((pool) => { - const threshold = pointsRewards.reward!.threshold; - const progress = Math.min(100, Math.round((pool.points % threshold) / threshold * 100)); - const remaining = threshold - (pool.points % threshold); - const isClaiming = claimingPool === pool.key; - const feedback = claimFeedback?.pool === pool.key ? claimFeedback : null; - return ( -
-
- {pool.name} - {pool.points} pts -
- {pool.eligible_configs.length > 0 && ( -
- {pool.eligible_configs.flatMap((cfg) => - cfg.all_products - ? [ - {cfg.category} - ] - : (cfg.product_names ?? []).map((name) => ( - - {name} - - )) + {pointsRewards?.enabled && + pointsRewards.reward && + pointsRewards.pools.length > 0 && ( +
+

+ + Récompenses +

+ {pointsRewards.reward.description && ( +

+ {pointsRewards.reward.description} +

+ )} + {(pointsRewards.reward.reward_items ?? []).filter( + (it: RewardItemConfig) => it.product_name, + ).length > 0 && ( +
+ {( + pointsRewards.reward.reward_items ?? [] + ).map( + (it: RewardItemConfig, idx: number) => ( + + + {it.product_name} + {it.quantity > 0 && + it.quantity !== 1 + ? ` ×${it.quantity}` + : ""} + {it.price > 0 + ? ` — ${it.price}€` + : ""} + + ), + )} +
+ )} +
+ {pointsRewards.pools.map((pool) => { + const threshold = + pointsRewards.reward!.threshold; + const progress = Math.min( + 100, + Math.round( + ((pool.points % threshold) / + threshold) * + 100, + ), + ); + const remaining = + threshold - (pool.points % threshold); + const isClaiming = + claimingPool === pool.key; + const feedback = + claimFeedback?.pool === pool.key + ? claimFeedback + : null; + return ( +
+
+ + {pool.name} + + + {pool.points} pts + +
+ {pool.eligible_configs.length > + 0 && ( +
+ {pool.eligible_configs.flatMap( + (cfg) => + cfg.all_products + ? [ + + { + cfg.category + } + , + ] + : ( + cfg.product_names ?? + [] + ).map( + ( + name, + ) => ( + + { + name + } + + ), + ), + )} +
+ )} +
+
+
+
+ {pool.rewards_available > 0 ? ( + + {pool.rewards_available}{" "} + récompense + {pool.rewards_available > + 1 + ? "s" + : ""}{" "} + disponible + {pool.rewards_available > + 1 + ? "s" + : ""} + + ) : ( + + Encore {remaining} pts + pour une récompense + )}
- )} -
-
-
-
- {pool.rewards_available > 0 ? ( - {pool.rewards_available} récompense{pool.rewards_available > 1 ? "s" : ""} disponible{pool.rewards_available > 1 ? "s" : ""} - ) : ( - Encore {remaining} pts pour une récompense + {feedback && ( +

+ {feedback.text} +

+ )} + {pool.rewards_available > 0 && ( + )}
- {feedback && ( -

{feedback.text}

- )} - {pool.rewards_available > 0 && ( - - )} -
- ); - })} + ); + })} +
-
- )} + )} {/* Bouton parrainage */} {appSettings.referral_enabled && ( @@ -494,10 +648,19 @@ function ConsultationHistorique() {
- {formatPrice((order.total_prix || 0) - (order.referral_used || 0))} + {formatPrice( + (order.total_prix || 0) - + (order.referral_used || 0), + )} {(order.referral_used || 0) > 0 && ( - {" "}— dont {formatPrice(order.referral_used ?? 0)} parrainage + {" "} + — dont{" "} + {formatPrice( + order.referral_used ?? + 0, + )}{" "} + parrainage )} @@ -512,6 +675,111 @@ function ConsultationHistorique() { )}
+ + {/* ============================================ */} + {/* MODAL — CHOIX DU PRODUIT RÉCOMPENSE */} + {/* ============================================ */} + {rewardModalPool && pointsRewards?.reward && ( +
+
e.stopPropagation()} + > +
+
+ +

Choisissez votre récompense

+
+ +
+ +

+ {rewardModalPool.name} —{" "} + {rewardModalPool.rewards_available} récompense + {rewardModalPool.rewards_available > 1 + ? "s" + : ""}{" "} + disponible + {rewardModalPool.rewards_available > 1 ? "s" : ""} +

+ +
+ {(rewardModalPool.eligible_reward_items ?? []).map( + (item) => { + const isSelected = + selectedProductId === item.product_id; + return ( + + ); + }, + )} +
+ +
+ + +
+
+
+ )} ); } diff --git a/frontend-prep/src/pages/User/OrderDetails.css b/frontend-prep/src/pages/User/OrderDetails.css index cd67a1b7..844666a1 100644 --- a/frontend-prep/src/pages/User/OrderDetails.css +++ b/frontend-prep/src/pages/User/OrderDetails.css @@ -802,6 +802,148 @@ animation-delay: 0.4s; } +/* ============================================ + RATING LIVREUR + ============================================ */ + +.rating-btn { + display: flex; + align-items: center; + gap: 0.4rem; + background: #f59e0b; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.5rem 1rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.75rem; + transition: opacity 0.2s; +} + +.rating-btn:hover { + opacity: 0.85; +} + +.rating-done-row { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + background: rgba(245, 158, 11, 0.1); + border-radius: 6px; +} + +.rating-done-text { + color: #f59e0b; + font-size: 0.85rem; +} + +.rating-star-filled { + color: #f59e0b; +} + +.rating-star-empty { + color: #6b7280; +} + +.rating-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 1000; +} + +.rating-modal-sheet { + background: var(--surface, #1a1a2e); + border-radius: 16px 16px 0 0; + padding: 1.5rem 1.5rem 2.5rem; + width: 100%; + max-width: 500px; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.rating-modal-title { + color: var(--text-primary, #fff); + font-size: 1.1rem; + font-weight: 700; + margin: 0; +} + +.rating-modal-subtitle { + color: var(--text-muted, #9ca3af); + font-size: 0.875rem; + margin: 0; +} + +.rating-stars-row { + display: flex; + gap: 0.5rem; + margin: 0.5rem 0; +} + +.rating-star-btn { + background: none; + border: none; + padding: 0; + cursor: pointer; + line-height: 0; +} + +.rating-comment-input { + width: 100%; + background: var(--bg, #0f0f1a); + border: 1px solid var(--border, #374151); + border-radius: 6px; + color: var(--text-primary, #fff); + padding: 0.75rem; + font-size: 0.875rem; + resize: vertical; + box-sizing: border-box; +} + +.rating-comment-input::placeholder { + color: var(--text-muted, #9ca3af); +} + +.rating-submit-btn { + background: #f59e0b; + color: #fff; + border: none; + border-radius: 6px; + padding: 0.75rem; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; +} + +.rating-submit-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.rating-submit-btn:not(:disabled):hover { + opacity: 0.85; +} + +.rating-cancel-btn { + background: none; + border: none; + color: var(--text-muted, #9ca3af); + font-size: 0.875rem; + cursor: pointer; + padding: 0.5rem; + text-align: center; +} + /* ============================================ PRINT STYLES ============================================ */ diff --git a/frontend-prep/src/pages/User/OrderDetails.tsx b/frontend-prep/src/pages/User/OrderDetails.tsx index e0f00f42..fd59b226 100644 --- a/frontend-prep/src/pages/User/OrderDetails.tsx +++ b/frontend-prep/src/pages/User/OrderDetails.tsx @@ -9,6 +9,8 @@ import { isUserAuthenticated, getProductById, getMediaUrl, + submitLivreurRating, + getOrderRatingStatus, } from "../../api/api"; import type { CompletedOrder, Product } from "../../api/api_types"; import { @@ -26,6 +28,7 @@ import { ShoppingBag, Camera, X, + Star, } from "lucide-react"; interface OrderProduct { @@ -73,6 +76,11 @@ function OrderDetails() { const [error, setError] = useState(""); const [showVideo, setShowVideo] = useState(false); const [currentVideoUrl, setCurrentVideoUrl] = useState(""); + const [ratingModal, setRatingModal] = useState(false); + const [selectedStars, setSelectedStars] = useState(0); + const [ratingComment, setRatingComment] = useState(""); + const [ratingSubmitting, setRatingSubmitting] = useState(false); + const [ratingDone, setRatingDone] = useState<{ rating: number; comment: string } | null>(null); // ✅ VÉRIFICATION INITIALE DE L'AUTHENTIFICATION useEffect(() => { @@ -315,6 +323,11 @@ function OrderDetails() { console.log("✅ [ORDER DETAILS] Données mappées:", orderData); setOrder(orderData); + + if (apiData.command_info?.command_status === "approved") { + const r = await getOrderRatingStatus(id); + if (r.rated) setRatingDone({ rating: r.rating!, comment: r.comment ?? "" }); + } } else { console.error("❌ [ORDER DETAILS] Erreur:", result.message); setError(result.message || "Erreur lors du chargement"); @@ -327,6 +340,17 @@ function OrderDetails() { } }; + const handleSubmitRating = async () => { + if (selectedStars === 0 || !order) return; + setRatingSubmitting(true); + const res = await submitLivreurRating(order.id, selectedStars, ratingComment); + setRatingSubmitting(false); + if (res.success) { + setRatingDone({ rating: selectedStars, comment: ratingComment }); + setRatingModal(false); + } + }; + const getProductCount = (totalPrix: number): number => { return Math.max(1, Math.round(totalPrix / 25)); }; @@ -446,6 +470,22 @@ function OrderDetails() {
)} + {order.status === "approved" && ( + ratingDone ? ( +
+ + + Noté {ratingDone.rating}/5{ratingDone.comment ? ` · "${ratingDone.comment}"` : ""} + +
+ ) : ( + + ) + )} +
@@ -759,6 +799,47 @@ function OrderDetails() {
+ {/* Modal notation livreur */} + {ratingModal && ( +
setRatingModal(false)}> +
e.stopPropagation()}> +

Noter la livraison

+ {order.livreur_assign && ( +

{order.livreur_assign}

+ )} +
+ {[1, 2, 3, 4, 5].map((star) => ( + + ))} +
+