chore: build
Frontend Admin - EAS Build / build (push) Canceled after 0s
Frontend Client - EAS Build / build (push) Canceled after 0s
Backend - Build & Lint / build (push) Failing after 25m18s
Frontend Web - Build & Lint / build (push) Failing after 9m58s

This commit is contained in:
Xor290
2026-08-06 12:06:05 +02:00
parent c034088bee
commit 22a8d5026c
174 changed files with 30315 additions and 16120 deletions
+144 -158
View File
@@ -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(&currentStock).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
}