1036 lines
35 KiB
Go
1036 lines
35 KiB
Go
package db
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"gestion/models"
|
||
"log"
|
||
"slices"
|
||
"strings"
|
||
"time"
|
||
|
||
"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 {
|
||
return -1
|
||
}
|
||
return r
|
||
}, s)
|
||
|
||
if len(sanitized) > 1000 {
|
||
sanitized = sanitized[:1000]
|
||
}
|
||
|
||
return strings.TrimSpace(sanitized)
|
||
}
|
||
|
||
func validateAddress(address string) error {
|
||
address = strings.TrimSpace(address)
|
||
|
||
if address == "" {
|
||
return fmt.Errorf("adresse vide non autorisée")
|
||
}
|
||
|
||
if len(address) > 500 {
|
||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||
}
|
||
|
||
if strings.ContainsAny(address, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x0B\x0C\x0E\x0F") {
|
||
return fmt.Errorf("adresse contient des caractères de contrôle interdits")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
type basketItem 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"`
|
||
}
|
||
|
||
// validateCommandStatus vérifie si le statut est valide
|
||
func validateCommandStatus(status string) error {
|
||
validStatuses := map[string]bool{
|
||
"pending": true,
|
||
"assigned": true,
|
||
"en_route": true,
|
||
"arrived": true,
|
||
"livre": true,
|
||
"approved": true,
|
||
"cancelled": true,
|
||
"disabled": true,
|
||
}
|
||
|
||
if !validStatuses[status] {
|
||
return fmt.Errorf("statut invalide: %s", status)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
var command *models.Command
|
||
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")
|
||
}
|
||
totalPrix := 0.0
|
||
for _, item := range basketItems {
|
||
totalPrix += item.Price
|
||
}
|
||
|
||
var cmdResult struct {
|
||
ID int `gorm:"column:id"`
|
||
ClientOrderID int `gorm:"column:client_order_id"`
|
||
}
|
||
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`,
|
||
username, "pending", adresse, totalPrix, username).Scan(&cmdResult).Error; err != nil {
|
||
return fmt.Errorf("erreur lors de la création de la commande: %w", err)
|
||
}
|
||
commandID := cmdResult.ID
|
||
|
||
productIDs := make([]int, 0, len(basketItems))
|
||
for _, item := range basketItems {
|
||
productIDs = append(productIDs, item.ProductID)
|
||
}
|
||
productNames, _ := d.GetProductNamesByIDs(productIDs)
|
||
|
||
cmdItems := make([]models.CommandItem, 0, len(basketItems))
|
||
for _, item := range basketItems {
|
||
productName := productNames[item.ProductID]
|
||
if productName == "" {
|
||
productName = "Produit inconnu"
|
||
}
|
||
cmdItems = append(cmdItems, models.CommandItem{
|
||
CommandID: commandID,
|
||
Produit: productName,
|
||
ProductID: item.ProductID,
|
||
Quantity: item.Quantity,
|
||
Price: item.Price,
|
||
IsReward: item.IsReward,
|
||
RewardPoolKey: item.RewardPoolKey,
|
||
})
|
||
}
|
||
if err := tx.Create(&cmdItems).Error; err != nil {
|
||
return fmt.Errorf("erreur lors de l'insertion des 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,
|
||
Status: "pending",
|
||
Total: totalPrix,
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return command, nil
|
||
}
|
||
|
||
func (d *Database) CreateCommandWithAddress(username, deliveryAddress string) (*models.Command, error) {
|
||
if err := validateUsername(username); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := validateAddress(deliveryAddress); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
client, err := d.GetClientByUsername(username)
|
||
if err != nil {
|
||
log.Printf("⚠️ Client non trouvé: %v", err)
|
||
}
|
||
|
||
clientNom := ""
|
||
clientPrenom := ""
|
||
clientTelephone := ""
|
||
if client != nil {
|
||
clientNom = sanitizeString(client.Nom)
|
||
clientPrenom = sanitizeString(client.Prenom)
|
||
clientTelephone = sanitizeString(client.Telephone)
|
||
}
|
||
|
||
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 création commande: %v", err)
|
||
return nil, err
|
||
}
|
||
|
||
sanitizedAddress := sanitizeLogMessage(deliveryAddress)
|
||
d.AddCommandLog(command.ID, "created",
|
||
fmt.Sprintf("Commande créée - Adresse: %s - Total: %.2f€ - Client: %s %s",
|
||
sanitizedAddress, totalPrix, sanitizeLogMessage(clientNom), sanitizeLogMessage(clientPrenom)),
|
||
username)
|
||
|
||
return command, nil
|
||
}
|
||
|
||
func (d *Database) GetApprovedCommands() ([]models.Command, error) {
|
||
var commands []models.Command
|
||
err := d.GDB.Where("status = ?", "approved").Order("created_at DESC").Find(&commands).Error
|
||
return commands, err
|
||
}
|
||
|
||
func (d *Database) GetAllCommands(status, username string) ([]map[string]any, error) {
|
||
if username != "" {
|
||
if err := validateUsername(username); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
if status != "" {
|
||
if err := validateCommandStatus(status); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
var rows []struct {
|
||
ID int `gorm:"column:id"`
|
||
Username string `gorm:"column:username"`
|
||
Status string `gorm:"column:status"`
|
||
Adresse string `gorm:"column:adresse"`
|
||
TotalPrix float64 `gorm:"column:total_prix"`
|
||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||
ProposedAddress *string `gorm:"column:proposed_address"`
|
||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||
CancelReason string `gorm:"column:cancel_reason"`
|
||
}
|
||
|
||
gdb := d.GDB.Table("commandes c").
|
||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix,
|
||
c.livreur_assign, c.created_at, c.updated_at,
|
||
c.proposed_address, c.address_proposal_status,
|
||
c.client_order_id AS client_order_number,
|
||
COALESCE(c.referral_used, 0) AS referral_used,
|
||
COALESCE(c.cancel_reason, '') AS cancel_reason`)
|
||
|
||
if status == "" {
|
||
gdb = gdb.Where("c.status IN ?", []string{"pending", "assigned", "en_route", "arrived", "livre"})
|
||
} else {
|
||
gdb = gdb.Where("c.status = ?", status)
|
||
}
|
||
|
||
if username != "" {
|
||
gdb = gdb.Where("c.username = ?", username)
|
||
}
|
||
|
||
if err := gdb.Order("c.created_at DESC").Limit(1000).Scan(&rows).Error; err != nil {
|
||
return nil, fmt.Errorf("erreur lors de la récupération des commandes: %w", err)
|
||
}
|
||
|
||
commands := make([]map[string]any, 0, len(rows))
|
||
for _, row := range rows {
|
||
command := map[string]any{
|
||
"id": row.ID,
|
||
"username": row.Username,
|
||
"status": row.Status,
|
||
"adresse": sanitizeString(row.Adresse),
|
||
"total_prix": row.TotalPrix,
|
||
"created_at": row.CreatedAt,
|
||
"updated_at": row.UpdatedAt,
|
||
"address_proposal_status": row.AddressProposalStatus,
|
||
"client_order_number": row.ClientOrderNumber,
|
||
"referral_used": row.ReferralUsed,
|
||
"cancel_reason": row.CancelReason,
|
||
}
|
||
|
||
if row.LivreurAssign != nil {
|
||
command["livreur_assign"] = *row.LivreurAssign
|
||
} else {
|
||
command["livreur_assign"] = nil
|
||
}
|
||
|
||
if row.ProposedAddress != nil {
|
||
command["proposed_address"] = *row.ProposedAddress
|
||
} else {
|
||
command["proposed_address"] = nil
|
||
}
|
||
|
||
commands = append(commands, command)
|
||
}
|
||
|
||
return commands, nil
|
||
}
|
||
|
||
func (d *Database) SetCommandReferralUsed(commandID int, amount float64) error {
|
||
return d.GDB.Exec(`UPDATE commandes SET referral_used = ? WHERE id = ?`, amount, commandID).Error
|
||
}
|
||
|
||
func (d *Database) GetCommandByID(id int) (map[string]any, error) {
|
||
var row struct {
|
||
ID int `gorm:"column:id"`
|
||
Username string `gorm:"column:username"`
|
||
Status string `gorm:"column:status"`
|
||
Adresse string `gorm:"column:adresse"`
|
||
TotalPrix float64 `gorm:"column:total_prix"`
|
||
LivreurAssign *string `gorm:"column:livreur_assign"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||
ProposedAddress *string `gorm:"column:proposed_address"`
|
||
AddressProposalStatus string `gorm:"column:address_proposal_status"`
|
||
ReferralUsed float64 `gorm:"column:referral_used"`
|
||
ClientOrderNumber int `gorm:"column:client_order_number"`
|
||
CancelReason string `gorm:"column:cancel_reason"`
|
||
DestLatitude float64 `gorm:"column:dest_latitude"`
|
||
DestLongitude float64 `gorm:"column:dest_longitude"`
|
||
}
|
||
|
||
if err := d.GDB.Table("commandes c").
|
||
Select(`c.id, c.username, c.status, c.adresse, c.total_prix, c.livreur_assign,
|
||
c.created_at, c.updated_at, c.proposed_address, c.address_proposal_status,
|
||
c.referral_used, c.client_order_id AS client_order_number,
|
||
COALESCE(c.cancel_reason, '') AS cancel_reason,
|
||
COALESCE(c.dest_latitude, 0) AS dest_latitude,
|
||
COALESCE(c.dest_longitude, 0) AS dest_longitude`).
|
||
Where("c.id = ?", id).
|
||
First(&row).Error; err != nil {
|
||
return nil, fmt.Errorf("erreur lors de la récupération de la commande: %w", err)
|
||
}
|
||
if row.ID == 0 {
|
||
return nil, fmt.Errorf("commande non trouvée")
|
||
}
|
||
|
||
command := map[string]any{
|
||
"id": row.ID,
|
||
"username": row.Username,
|
||
"status": row.Status,
|
||
"adresse": row.Adresse,
|
||
"total_prix": row.TotalPrix,
|
||
"created_at": row.CreatedAt,
|
||
"updated_at": row.UpdatedAt,
|
||
"address_proposal_status": row.AddressProposalStatus,
|
||
"referral_used": row.ReferralUsed,
|
||
"client_order_number": row.ClientOrderNumber,
|
||
"cancel_reason": row.CancelReason,
|
||
"dest_latitude": row.DestLatitude,
|
||
"dest_longitude": row.DestLongitude,
|
||
}
|
||
|
||
if row.LivreurAssign != nil {
|
||
command["livreur_assign"] = *row.LivreurAssign
|
||
} else {
|
||
command["livreur_assign"] = nil
|
||
}
|
||
|
||
if row.ProposedAddress != nil {
|
||
command["proposed_address"] = *row.ProposedAddress
|
||
} else {
|
||
command["proposed_address"] = nil
|
||
}
|
||
|
||
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. 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"`
|
||
}
|
||
|
||
if err := d.GDB.Table("commandes").
|
||
Select("dest_latitude, dest_longitude").
|
||
Where("livreur_assign = ? AND status IN (?, ?, ?) AND dest_latitude IS NOT NULL AND dest_latitude != 0 AND dest_longitude IS NOT NULL AND dest_longitude != 0",
|
||
livreurUsername, "livre", "delivered", "approved").
|
||
Order("updated_at DESC").
|
||
Limit(1).
|
||
Scan(&result).Error; err != nil {
|
||
return 0, 0, fmt.Errorf("aucune livraison précédente pour %s: %w", livreurUsername, err)
|
||
}
|
||
|
||
if result.DestLatitude == 0 || result.DestLongitude == 0 {
|
||
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
|
||
}
|
||
|
||
// GetClientOrderID retourne le client_order_id (numéro perso du client) pour un commandID global.
|
||
// Retourne commandID en fallback si introuvable.
|
||
func (d *Database) GetClientOrderID(commandID int) int {
|
||
var result struct {
|
||
ClientOrderID int `gorm:"column:client_order_id"`
|
||
}
|
||
if err := d.GDB.Model(&models.Command{}).Select("client_order_id").Where("id = ?", commandID).First(&result).Error; err != nil || result.ClientOrderID == 0 {
|
||
return commandID
|
||
}
|
||
return result.ClientOrderID
|
||
}
|
||
|
||
func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) error {
|
||
if len(deliveryAddress) > 500 {
|
||
return fmt.Errorf("adresse trop longue (max 500 caractères)")
|
||
}
|
||
if strings.TrimSpace(deliveryAddress) == "" {
|
||
return fmt.Errorf("adresse vide non autorisée")
|
||
}
|
||
|
||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||
"adresse": deliveryAddress,
|
||
"updated_at": time.Now(),
|
||
})
|
||
if result.Error != nil {
|
||
return fmt.Errorf("erreur lors de la mise à jour de l'adresse: %w", result.Error)
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return fmt.Errorf("commande non trouvée")
|
||
}
|
||
|
||
log.Printf("✅ Adresse commande %d mise à jour", commandID)
|
||
return nil
|
||
}
|
||
|
||
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
|
||
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
|
||
if err := validateAddress(proposedAddress); err != nil {
|
||
return err
|
||
}
|
||
|
||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||
"proposed_address": proposedAddress,
|
||
"address_proposal_status": "pending",
|
||
"updated_at": time.Now(),
|
||
})
|
||
if result.Error != nil {
|
||
return fmt.Errorf("erreur proposition adresse: %w", result.Error)
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return fmt.Errorf("commande non trouvée")
|
||
}
|
||
|
||
d.AddCommandLog(commandID, "address_proposed",
|
||
fmt.Sprintf("Nouvelle adresse proposée par %s: %s", proposedBy, proposedAddress),
|
||
proposedBy)
|
||
|
||
log.Printf("✅ Adresse proposée pour commande %d par %s", commandID, proposedBy)
|
||
return nil
|
||
}
|
||
|
||
// RespondToAddressProposal accepte ou refuse la proposition d'adresse
|
||
func (d *Database) RespondToAddressProposal(commandID int, clientUsername string, accepted bool) error {
|
||
var query string
|
||
if accepted {
|
||
query = `UPDATE commandes
|
||
SET adresse = proposed_address, proposed_address = NULL,
|
||
address_proposal_status = 'accepted', updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
|
||
} else {
|
||
query = `UPDATE commandes
|
||
SET proposed_address = NULL, address_proposal_status = 'rejected',
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ? AND username = ? AND address_proposal_status = 'pending'`
|
||
}
|
||
|
||
result := d.GDB.Exec(query, commandID, clientUsername)
|
||
if result.Error != nil {
|
||
return fmt.Errorf("erreur réponse proposition adresse: %w", result.Error)
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return fmt.Errorf("aucune proposition en attente pour cette commande")
|
||
}
|
||
|
||
action := "refusée"
|
||
if accepted {
|
||
action = "acceptée"
|
||
}
|
||
d.AddCommandLog(commandID, "address_proposal_"+action,
|
||
fmt.Sprintf("Proposition d'adresse %s par le client %s", action, clientUsername),
|
||
clientUsername)
|
||
|
||
log.Printf("✅ Proposition adresse %s pour commande %d", action, commandID)
|
||
return nil
|
||
}
|
||
|
||
// UpdateCommandStatus met à jour le statut d'une commande
|
||
func (d *Database) UpdateCommandStatus(commandID int, status string) error {
|
||
if err := validateCommandStatus(status); err != nil {
|
||
return err
|
||
}
|
||
|
||
result := d.GDB.Model(&models.Command{}).Where("id = ?", commandID).Updates(map[string]any{
|
||
"status": status,
|
||
"updated_at": time.Now(),
|
||
})
|
||
if result.Error != nil {
|
||
return fmt.Errorf("erreur lors de la mise à jour du statut: %w", result.Error)
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return fmt.Errorf("commande non trouvée")
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (d *Database) AddCommandLog(commandID int, status, message, author string) error {
|
||
sanitizedMessage := sanitizeLogMessage(message)
|
||
sanitizedAuthor := sanitizeLogMessage(author)
|
||
|
||
if err := d.GDB.Create(&models.CommandLog{
|
||
CommandID: commandID,
|
||
Status: status,
|
||
Message: sanitizedMessage,
|
||
Author: sanitizedAuthor,
|
||
}).Error; err != nil {
|
||
log.Printf("⚠️ Avertissement: impossible d'ajouter le log (table command_logs peut-être manquante): %v", err)
|
||
return nil
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// GetCommandLogs récupère tous les logs d'une commande
|
||
func (d *Database) GetCommandLogs(commandID int) ([]map[string]any, error) {
|
||
var rows []models.CommandLog
|
||
|
||
if err := d.GDB.Where("command_id = ?", commandID).Order("created_at ASC").Find(&rows).Error; err != nil {
|
||
log.Printf("⚠️ Avertissement: impossible de récupérer les logs: %v", err)
|
||
return []map[string]any{}, nil
|
||
}
|
||
|
||
logs := make([]map[string]any, 0, len(rows))
|
||
for _, row := range rows {
|
||
logs = append(logs, map[string]any{
|
||
"id": row.ID,
|
||
"command_id": row.CommandID,
|
||
"status": row.Status,
|
||
"message": row.Message,
|
||
"author": row.Author,
|
||
"created_at": row.CreatedAt,
|
||
})
|
||
}
|
||
|
||
return logs, nil
|
||
}
|
||
|
||
func sanitizeLogMessage(message string) string {
|
||
sanitized := strings.Map(func(r rune) rune {
|
||
if r < 32 || r == 127 {
|
||
return -1
|
||
}
|
||
return r
|
||
}, message)
|
||
|
||
if len(sanitized) > 1000 {
|
||
sanitized = sanitized[:1000]
|
||
}
|
||
|
||
return sanitized
|
||
}
|
||
|
||
func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (int, error) {
|
||
var totalPoints int
|
||
var cmdUsernameOut string
|
||
var livreurAssignOut string
|
||
|
||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||
var cmd struct {
|
||
Status string `gorm:"column:status"`
|
||
Username string `gorm:"column:username"`
|
||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||
TotalPrix float64 `gorm:"column:total_prix"`
|
||
}
|
||
if err := tx.Raw(`
|
||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign, total_prix
|
||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||
log.Printf("❌ [ValidateAtomic] Erreur SELECT: %v", err)
|
||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||
}
|
||
if cmd.Username == "" {
|
||
log.Printf("❌ [ValidateAtomic] Commande %d non trouvée", commandID)
|
||
return fmt.Errorf("commande non trouvée")
|
||
}
|
||
|
||
log.Printf("📋 [ValidateAtomic] Commande trouvée - status=%s, client=%s, livreur=%s",
|
||
cmd.Status, cmd.Username, cmd.LivreurAssign)
|
||
|
||
validStatuses := []string{"assigned", "en_route", "pending", "livre"}
|
||
if !slices.Contains(validStatuses, cmd.Status) {
|
||
log.Printf("❌ [ValidateAtomic] Statut invalide pour validation: %s", cmd.Status)
|
||
return fmt.Errorf("statut invalide pour validation: %s", cmd.Status)
|
||
}
|
||
|
||
if cmd.Status == "approved" {
|
||
log.Printf("⚠️ [ValidateAtomic] Commande %d déjà approuvée", commandID)
|
||
return fmt.Errorf("commande déjà approuvée")
|
||
}
|
||
|
||
result := tx.Exec(`
|
||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ? AND status = ?`, commandID, cmd.Status)
|
||
if result.Error != nil {
|
||
log.Printf("❌ [ValidateAtomic] Erreur UPDATE: %v", result.Error)
|
||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
log.Printf("❌ [ValidateAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
|
||
return fmt.Errorf("commande déjà modifiée par une autre requête")
|
||
}
|
||
|
||
if cmd.Username != "" {
|
||
log.Printf("🔍 [ValidateAtomic] Calcul points pour client: %s", cmd.Username)
|
||
|
||
points, _, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
|
||
if err != nil {
|
||
log.Printf("❌ [ValidateAtomic] Erreur calcul/ajout points: %v", err)
|
||
return fmt.Errorf("erreur attribution points: %w", err)
|
||
}
|
||
totalPoints = points
|
||
|
||
log.Printf("✅ [ValidateAtomic] %d points attribués à %s", totalPoints, cmd.Username)
|
||
|
||
if err := tx.Exec(`
|
||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||
WHERE username = ?`, cmd.Username).Error; err != nil {
|
||
log.Printf("⚠️ [ValidateAtomic] Erreur incrémentation compteur: %v", err)
|
||
} else {
|
||
log.Printf("✅ [ValidateAtomic] Compteur commandes incrémenté pour %s", cmd.Username)
|
||
}
|
||
}
|
||
|
||
if err := tx.Exec(`
|
||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||
commandID, "approved",
|
||
fmt.Sprintf("Livraison validée par admin %s - %d points attribués", adminUsername, totalPoints),
|
||
adminUsername).Error; err != nil {
|
||
log.Printf("⚠️ [ValidateAtomic] Erreur ajout log: %v", err)
|
||
}
|
||
|
||
cmdUsernameOut = cmd.Username
|
||
livreurAssignOut = cmd.LivreurAssign
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
|
||
log.Printf("🎉 [ValidateAtomic] SUCCÈS - Commande %d validée, %d points attribués",
|
||
commandID, totalPoints)
|
||
|
||
if livreurAssignOut != "" {
|
||
log.Printf("📦 [ValidateAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
|
||
go func() {
|
||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||
log.Printf("⚠️ [ValidateAtomic] Erreur optimisation queue: %v", err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
go func() {
|
||
commandCacheKey := fmt.Sprintf("command:%d", commandID)
|
||
Redis.Del(RedisCtx, commandCacheKey)
|
||
|
||
if cmdUsernameOut != "" {
|
||
clientCacheKey := fmt.Sprintf("client:%s", cmdUsernameOut)
|
||
Redis.Del(RedisCtx, clientCacheKey)
|
||
|
||
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", cmdUsernameOut)
|
||
Redis.Del(RedisCtx, clientCommandsCacheKey)
|
||
}
|
||
|
||
log.Printf("✅ [ValidateAtomic] Caches invalidés pour cmd %d", commandID)
|
||
}()
|
||
|
||
return totalPoints, nil
|
||
}
|
||
|
||
// ApproveDeliveryAtomic - Version atomique pour approbation client
|
||
func (d *Database) ApproveDeliveryAtomic(commandID int, username string) (int, string, error) {
|
||
log.Printf("🔒 [ApproveAtomic] START - cmd=%d, client=%s", commandID, username)
|
||
|
||
var totalPoints int
|
||
var pointCategory string
|
||
var livreurAssignOut string
|
||
|
||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||
var cmd struct {
|
||
Status string `gorm:"column:status"`
|
||
Username string `gorm:"column:username"`
|
||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||
}
|
||
if err := tx.Raw(`
|
||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||
log.Printf("❌ [ApproveAtomic] Erreur SELECT: %v", err)
|
||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||
}
|
||
if cmd.Username == "" {
|
||
log.Printf("❌ [ApproveAtomic] Commande %d non trouvée", commandID)
|
||
return fmt.Errorf("commande non trouvée")
|
||
}
|
||
|
||
log.Printf("📋 [ApproveAtomic] Commande trouvée - status=%s, owner=%s", cmd.Status, cmd.Username)
|
||
|
||
if cmd.Username != username {
|
||
log.Printf("❌ [ApproveAtomic] Commande n'appartient pas à %s (propriétaire: %s)",
|
||
username, cmd.Username)
|
||
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)
|
||
}
|
||
|
||
result := tx.Exec(`
|
||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ? AND status = 'livre' AND username = ?`, commandID, username)
|
||
if result.Error != nil {
|
||
log.Printf("❌ [ApproveAtomic] Erreur UPDATE: %v", result.Error)
|
||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
log.Printf("❌ [ApproveAtomic] Commande %d déjà modifiée (race condition évitée)", commandID)
|
||
return fmt.Errorf("commande déjà approuvée ou modifiée")
|
||
}
|
||
|
||
log.Printf("✅ [ApproveAtomic] Statut mis à jour: livre → approved")
|
||
|
||
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, username)
|
||
if err != nil {
|
||
log.Printf("❌ [ApproveAtomic] Erreur calcul points: %v", err)
|
||
return fmt.Errorf("erreur attribution points: %w", err)
|
||
}
|
||
totalPoints = pts
|
||
pointCategory = cat
|
||
|
||
log.Printf("✅ [ApproveAtomic] %d points [%s] attribués à %s", totalPoints, pointCategory, username)
|
||
|
||
if err := tx.Exec(`
|
||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||
WHERE username = ?`, username).Error; err != nil {
|
||
log.Printf("⚠️ [ApproveAtomic] Erreur incrémentation compteur: %v", err)
|
||
}
|
||
|
||
if err := tx.Exec(`
|
||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||
commandID, "approved",
|
||
fmt.Sprintf("Livraison confirmée par le client %s - %d points [%s] attribués", username, totalPoints, pointCategory),
|
||
username).Error; err != nil {
|
||
log.Printf("⚠️ [ApproveAtomic] Erreur ajout log: %v", err)
|
||
}
|
||
|
||
livreurAssignOut = cmd.LivreurAssign
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
if errors.Is(err, errAlreadyApproved) {
|
||
return 0, "", nil
|
||
}
|
||
return 0, "", err
|
||
}
|
||
|
||
log.Printf("🎉 [ApproveAtomic] SUCCÈS - Commande %d approuvée, %d points [%s] attribués",
|
||
commandID, totalPoints, pointCategory)
|
||
|
||
if livreurAssignOut != "" {
|
||
log.Printf("📦 [ApproveAtomic] Optimisation queue pour livreur: %s", livreurAssignOut)
|
||
go func() {
|
||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||
log.Printf("⚠️ [ApproveAtomic] Erreur optimisation queue: %v", err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
go func() {
|
||
commandCacheKey := fmt.Sprintf("command:%d", commandID)
|
||
Redis.Del(RedisCtx, commandCacheKey)
|
||
|
||
clientCacheKey := fmt.Sprintf("client:%s", username)
|
||
Redis.Del(RedisCtx, clientCacheKey)
|
||
|
||
clientCommandsCacheKey := fmt.Sprintf("client:%s:commands", username)
|
||
Redis.Del(RedisCtx, clientCommandsCacheKey)
|
||
|
||
log.Printf("✅ [ApproveAtomic] Caches invalidés")
|
||
}()
|
||
|
||
return totalPoints, pointCategory, nil
|
||
}
|
||
|
||
// ApproveDeliveryAtomicByStaff - Confirmation de réception par admin ou cabine à la place du client
|
||
func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername string) (int, string, string, error) {
|
||
log.Printf("🔒 [ApproveAtomicStaff] START - cmd=%d, staff=%s", commandID, staffUsername)
|
||
|
||
var totalPoints int
|
||
var pointCategory string
|
||
var clientUsernameOut string
|
||
var livreurAssignOut string
|
||
|
||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||
var cmd struct {
|
||
Status string `gorm:"column:status"`
|
||
Username string `gorm:"column:username"`
|
||
LivreurAssign string `gorm:"column:livreur_assign"`
|
||
}
|
||
if err := tx.Raw(`
|
||
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
|
||
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmd).Error; err != nil {
|
||
return fmt.Errorf("erreur lecture commande: %w", err)
|
||
}
|
||
if cmd.Username == "" {
|
||
return fmt.Errorf("commande non trouvée")
|
||
}
|
||
|
||
if cmd.Status != "livre" {
|
||
return fmt.Errorf("commande doit être en statut 'livre' (statut actuel: %s)", cmd.Status)
|
||
}
|
||
|
||
result := tx.Exec(`
|
||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ? AND status = 'livre'`, commandID)
|
||
if result.Error != nil {
|
||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
return fmt.Errorf("commande déjà approuvée ou modifiée")
|
||
}
|
||
|
||
pts, cat, err := d.CalculateAndAddPointsForCommandTx(tx, commandID, cmd.Username)
|
||
if err != nil {
|
||
return fmt.Errorf("erreur attribution points: %w", err)
|
||
}
|
||
totalPoints = pts
|
||
pointCategory = cat
|
||
|
||
if err := tx.Exec(`
|
||
UPDATE clients SET command = command + 1, updated_at = CURRENT_TIMESTAMP
|
||
WHERE username = ?`, cmd.Username).Error; err != nil {
|
||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur incrémentation compteur: %v", err)
|
||
}
|
||
|
||
if err := tx.Exec(`
|
||
INSERT INTO command_logs (command_id, status, message, author, created_at)
|
||
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
|
||
commandID, "approved",
|
||
fmt.Sprintf("Réception confirmée par %s au nom du client %s - %d points attribués", staffUsername, cmd.Username, totalPoints),
|
||
staffUsername).Error; err != nil {
|
||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur log: %v", err)
|
||
}
|
||
|
||
clientUsernameOut = cmd.Username
|
||
livreurAssignOut = cmd.LivreurAssign
|
||
return nil
|
||
})
|
||
|
||
if err != nil {
|
||
return 0, "", "", err
|
||
}
|
||
|
||
log.Printf("🎉 [ApproveAtomicStaff] SUCCÈS - cmd=%d approuvée par %s, %d points → client %s",
|
||
commandID, staffUsername, totalPoints, clientUsernameOut)
|
||
|
||
if livreurAssignOut != "" {
|
||
go func() {
|
||
if err := d.CompleteDeliveryAndProcessNext(livreurAssignOut, commandID); err != nil {
|
||
log.Printf("⚠️ [ApproveAtomicStaff] Erreur queue: %v", err)
|
||
}
|
||
}()
|
||
}
|
||
|
||
go func() {
|
||
Redis.Del(RedisCtx, fmt.Sprintf("command:%d", commandID))
|
||
Redis.Del(RedisCtx, fmt.Sprintf("client:%s", clientUsernameOut))
|
||
Redis.Del(RedisCtx, fmt.Sprintf("client:%s:commands", clientUsernameOut))
|
||
}()
|
||
|
||
return totalPoints, pointCategory, clientUsernameOut, nil
|
||
}
|
||
|
||
func (d *Database) SetCommandCancelReason(commandID int, reason string) error {
|
||
if len(reason) > 500 {
|
||
reason = reason[:500]
|
||
}
|
||
return d.GDB.Exec(
|
||
`UPDATE commandes SET cancel_reason = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||
reason, commandID,
|
||
).Error
|
||
}
|