chore: update

This commit is contained in:
2026-03-03 23:42:23 +01:00
parent 52b059fafa
commit 0073f80a48
95 changed files with 2720 additions and 40619 deletions
+6
View File
@@ -668,6 +668,12 @@ func ForceValidateDelivery(c *gin.Context) {
clientUsername, _ := command["username"].(string)
livreurAssign, _ := command["livreur_assign"].(string)
// Notifier le client
if clientUsername != "" {
clientMsg := fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID)
database.NotifyClient(clientUsername, commandID, "livre", clientMsg)
}
if err := database.IncrementClientCommandCount(clientUsername); err != nil {
log.Printf("⚠️ Erreur compteur commandes: %v", err)
}
+25
View File
@@ -359,6 +359,31 @@ func UpdateDeliveryStatus(c *gin.Context) {
}
database.AddCommandLog(commandID, req.Status, message, usernameStr)
// ✅ NOTIFICATION CLIENT
clientUsername, _ := command["username"].(string)
if clientUsername != "" {
var clientMsg string
switch req.Status {
case "support":
clientMsg = fmt.Sprintf("Votre commande #%d est prise en charge", commandID)
case "en_route":
if etaMinutes > 0 {
clientMsg = fmt.Sprintf("Votre commande #%d est en route ! Arrivée dans ~%d min", commandID, etaMinutes)
} else {
clientMsg = fmt.Sprintf("Votre commande #%d est en route !", commandID)
}
case "arrived":
clientMsg = fmt.Sprintf("Votre livreur est arrivé pour la commande #%d", commandID)
case "livre":
clientMsg = fmt.Sprintf("Votre commande #%d a été livrée. Merci !", commandID)
case "failed":
clientMsg = fmt.Sprintf("Échec de livraison pour la commande #%d", commandID)
}
if clientMsg != "" {
database.NotifyClient(clientUsername, commandID, req.Status, clientMsg)
}
}
// ✅ GESTION SPÉCIALE SELON LE STATUT
switch req.Status {
case "livre":
+138
View File
@@ -107,6 +107,144 @@ func GetClientNotifications(c *gin.Context) {
})
}
// RegisterLivreurPushToken enregistre le push token Expo d'un livreur
// POST /api/v1/livreur/push-token
func RegisterLivreurPushToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
var req struct {
PushToken string `json:"push_token" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "push_token requis"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.SaveUserPushToken(username, req.PushToken); err != nil {
log.Printf("❌ [LIVREUR_PUSH_TOKEN] Erreur sauvegarde token pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur enregistrement push token"})
return
}
log.Printf("✅ [LIVREUR_PUSH_TOKEN] Token enregistré pour livreur %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// UnregisterLivreurPushToken supprime le push token d'un livreur (au logout)
// DELETE /api/v1/livreur/push-token
func UnregisterLivreurPushToken(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
database := c.MustGet("database").(*db.Database)
if err := database.DeleteUserPushToken(username); err != nil {
log.Printf("❌ [LIVREUR_PUSH_TOKEN] Erreur suppression token pour %s: %v", username, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur suppression push token"})
return
}
log.Printf("✅ [LIVREUR_PUSH_TOKEN] Token supprimé pour livreur %s", username)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// GetLivreurNotifications retourne les notifications du livreur connecté
// GET /api/v1/livreur/notifications
func GetLivreurNotifications(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, 49).Result()
if err != nil {
log.Printf("❌ [LIVREUR_NOTIFICATIONS] Erreur Redis: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur récupération notifications"})
return
}
type Notification struct {
CommandID int `json:"command_id"`
Type string `json:"type"`
Message string `json:"message"`
CreatedAt string `json:"created_at"`
Read bool `json:"read"`
}
notifications := make([]Notification, 0, len(results))
unreadCount := 0
for _, raw := range results {
var n Notification
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
notifications = append(notifications, n)
if !n.Read {
unreadCount++
}
}
log.Printf("✅ [LIVREUR_NOTIFICATIONS] %d notifications pour %s (%d non lues)", len(notifications), username, unreadCount)
c.JSON(http.StatusOK, gin.H{
"notifications": notifications,
"unread_count": unreadCount,
"total": len(notifications),
})
}
// MarkLivreurNotificationsRead marque toutes les notifications du livreur comme lues
// POST /api/v1/livreur/notifications/read
func MarkLivreurNotificationsRead(c *gin.Context) {
username := c.GetString("username")
if username == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Non authentifié"})
return
}
notifKey := "notifications:" + username
results, err := db.Redis.LRange(db.RedisCtx, notifKey, 0, -1).Result()
if err != nil {
log.Printf("❌ [LIVREUR_MARK_READ] Erreur Redis LRange: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lecture notifications"})
return
}
markedCount := 0
for i, raw := range results {
var n map[string]interface{}
if err := json.Unmarshal([]byte(raw), &n); err != nil {
continue
}
if read, ok := n["read"].(bool); ok && read {
continue
}
n["read"] = true
updated, _ := json.Marshal(n)
db.Redis.LSet(db.RedisCtx, notifKey, int64(i), string(updated))
markedCount++
}
log.Printf("✅ [LIVREUR_MARK_READ] %d notifications marquées lues pour %s", markedCount, username)
c.JSON(http.StatusOK, gin.H{
"success": true,
"marked_count": markedCount,
})
}
// MarkNotificationsRead marque toutes les notifications comme lues
// POST /api/v1/notifications/read
func MarkNotificationsRead(c *gin.Context) {
+50
View File
@@ -5,6 +5,7 @@
package handlers
import (
"fmt"
"gestion/db"
"gestion/models"
"gestion/services"
@@ -352,6 +353,45 @@ func ValidateBasket(c *gin.Context) {
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
// ============================================
// 1️⃣b Vérifier le minimum de commande selon la zone
// ============================================
var cartTotal float64
for _, item := range items {
if price, ok := item["price"].(float64); ok {
cartTotal += price
}
}
zoneResult := checkDeliveryZone(req.DeliveryAddress, cartTotal)
if !zoneResult.OK {
if zoneResult.ZoneName == "inconnue" {
log.Printf("❌ [CHECKOUT] Aucun code postal trouvé dans l'adresse: %s", req.DeliveryAddress)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Adresse invalide : aucun code postal détecté",
})
} else if zoneResult.ZoneName == "hors zone" {
log.Printf("❌ [CHECKOUT] Code postal %s hors zone de livraison", zoneResult.PostalCode)
c.JSON(http.StatusBadRequest, gin.H{
"error": "Livraison non disponible pour ce code postal",
"postal_code": zoneResult.PostalCode,
})
} else {
log.Printf("❌ [CHECKOUT] Total %.2f€ insuffisant pour %s (minimum %.2f€)", cartTotal, zoneResult.ZoneName, zoneResult.MinAmount)
c.JSON(http.StatusBadRequest, gin.H{
"error": fmt.Sprintf("Montant minimum de commande non atteint pour votre zone (%.0f€ minimum)", zoneResult.MinAmount),
"zone": zoneResult.ZoneName,
"minimum": zoneResult.MinAmount,
"cart_total": cartTotal,
"missing": zoneResult.MinAmount - cartTotal,
"postal_code": zoneResult.PostalCode,
})
}
return
}
log.Printf("✅ [CHECKOUT] Zone OK: %s, total=%.2f€ >= %.2f€", zoneResult.ZoneName, cartTotal, zoneResult.MinAmount)
// ============================================
// 2️⃣ Créer la commande (qui décrémente automatiquement le stock)
// ============================================
@@ -439,6 +479,16 @@ func ValidateBasket(c *gin.Context) {
log.Printf("⚠️ [CHECKOUT] Erreur mise à jour statut livreur: %v", err)
}
// Notifier le livreur de la nouvelle commande
notifMsg := fmt.Sprintf("Nouvelle commande #%d assignée - Livraison dans ~%d min (%.2f km)", commandID, travelTime, distance)
if notifErr := database.NotifyLivreur(nearest.Username, commandID, "new_assignment", notifMsg); notifErr != nil {
log.Printf("⚠️ [CHECKOUT] Erreur notification livreur: %v", notifErr)
}
// Notifier le client
clientMsg := fmt.Sprintf("Votre commande #%d est confirmée ! Livraison prévue dans ~%d min", commandID, travelTime)
database.NotifyClient(usernameStr, commandID, "assigned", clientMsg)
assigned = true
assignInfo = gin.H{
"username": nearest.Username,
+38 -2
View File
@@ -114,6 +114,22 @@ func validatePrice(quantity float64, price float64) error {
return nil
}
func validateUnit(unit string) error {
validUnits := map[string]bool{
"u": true, // unité
"kg": true, // kilogramme
"g": true, // gramme
"bag": true, // sac
"l": true, // litre
"cl": true, // centilitre
"pcs": true, // pièces
}
if !validUnits[unit] {
return fmt.Errorf("unité invalide : valeurs acceptées : u, kg, g, bag, l, cl, pcs")
}
return nil
}
func validateCategory(category string) error {
// Nettoyage
category = strings.ToLower(strings.TrimSpace(category))
@@ -205,6 +221,10 @@ func CreateProduct(c *gin.Context) {
category := strings.TrimSpace(c.PostForm("category"))
description := strings.TrimSpace(c.PostForm("description"))
stockStr := c.PostForm("stock")
unit := strings.ToLower(strings.TrimSpace(c.PostForm("unit")))
if unit == "" {
unit = "u"
}
// ✅ VALIDATION STRICTE
if err := validateProductName(name); err != nil {
@@ -231,6 +251,11 @@ func CreateProduct(c *gin.Context) {
return
}
if err := validateUnit(unit); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// ✅ VALIDER LE STOCK
stock, err := strconv.ParseFloat(stockStr, 64)
if err != nil {
@@ -296,6 +321,7 @@ func CreateProduct(c *gin.Context) {
Category: category,
Description: description,
Stock: stock,
Unit: unit,
Prices: prices,
}
@@ -581,6 +607,7 @@ func UpdateProduct(c *gin.Context) {
Category string `json:"category"`
Description string `json:"description"`
Stock float64 `json:"stock"`
Unit string `json:"unit"`
Prices []models.ProductPrice `json:"prices"`
}
@@ -605,6 +632,14 @@ func UpdateProduct(c *gin.Context) {
return
}
if updateData.Unit == "" {
updateData.Unit = "u"
}
if err := validateUnit(updateData.Unit); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateStock(updateData.Stock); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -627,8 +662,8 @@ func UpdateProduct(c *gin.Context) {
// ✅ UPDATE PRODUIT
updateQuery := `
UPDATE products
SET name = $1, category = $2, description = $3, stock = $4, updated_at = $5
WHERE id = $6
SET name = $1, category = $2, description = $3, stock = $4, unit = $5, updated_at = $6
WHERE id = $7
`
_, err = database.Exec(updateQuery,
@@ -636,6 +671,7 @@ func UpdateProduct(c *gin.Context) {
updateData.Category,
updateData.Description,
updateData.Stock,
updateData.Unit,
time.Now(),
id,
)
+124
View File
@@ -0,0 +1,124 @@
package handlers
import "regexp"
// ============================================================
// Zones de livraison — minimum de commande par code postal
// ============================================================
// Remplis les listes de codes postaux quand tu les as.
// Un code postal absent de toutes les zones → commande refusée.
// ============================================================
type deliveryZone struct {
Name string
MinAmount float64
codes map[string]struct{}
}
var deliveryZones = []deliveryZone{
{
Name: "Zone 30€",
MinAmount: 30.0,
codes: postalSet([]string{
"44000",
"44100",
"44200",
"44300",
}),
},
{
Name: "Zone 50€",
MinAmount: 50.0,
codes: postalSet([]string{
"44400", // Rezé
"44880", // Les Sorinières / Sautron
"44120", // Vertou
"44230", // Saint-Sébastien-sur-Loire
"44115", // Basse-Goulaine / Haute-Goulaine
"44980", // Sainte-Luce-sur-Loire
"44470", // Carquefou
"44240", // La Chapelle-sur-Erdre
"44700", // Orvault
"44800", // Saint-Herblain
"44340", // Bouguenais
"44620", // La Montagne
"44830", // Bouaye
}),
},
{
Name: "Zone 100€",
MinAmount: 100.0,
codes: postalSet([]string{
"44860", // Pont-Saint-Martin / Saint-Aignan-Grandlieu
"44220", // Couëron
"44118", // La Chevrolière
"44830", // Brains
"44710", // Saint-Léger-les-Vignes
"44690", // La Haie-Fouassière
"44470", // Mauves-sur-Loire
"44240", // Sucé-sur-Erdre
"44119", // Grandchamp-des-Fontaines
}),
},
}
var postalCodeRe = regexp.MustCompile(`\b(\d{5})\b`)
// postalSet convertit une slice de codes en set pour lookup O(1).
func postalSet(codes []string) map[string]struct{} {
m := make(map[string]struct{}, len(codes))
for _, c := range codes {
m[c] = struct{}{}
}
return m
}
// extractPostalCode extrait le premier code postal à 5 chiffres d'une adresse.
func extractPostalCode(address string) string {
m := postalCodeRe.FindStringSubmatch(address)
if len(m) < 2 {
return ""
}
return m[1]
}
// zoneCheckResult est le résultat de la vérification de zone.
type zoneCheckResult struct {
PostalCode string
ZoneName string
MinAmount float64
OK bool
}
// checkDeliveryZone vérifie si le total respecte le minimum de la zone de l'adresse.
// Code postal introuvable → OK = false (refus).
// Code postal hors de toutes les zones → OK = false (refus).
func checkDeliveryZone(deliveryAddress string, total float64) zoneCheckResult {
code := extractPostalCode(deliveryAddress)
if code == "" {
return zoneCheckResult{
PostalCode: "",
ZoneName: "inconnue",
MinAmount: 0,
OK: false,
}
}
for _, zone := range deliveryZones {
if _, found := zone.codes[code]; found {
return zoneCheckResult{
PostalCode: code,
ZoneName: zone.Name,
MinAmount: zone.MinAmount,
OK: total >= zone.MinAmount,
}
}
}
return zoneCheckResult{
PostalCode: code,
ZoneName: "hors zone",
MinAmount: 0,
OK: false,
}
}