Files
projet_gestion_commande/backend/gestion/db/db_cancel_command.go
T
Nuxgrid 734493363f
Backend - Build & Lint / build (push) Failing after 29m5s
Frontend Client - EAS Build / build (push) Failing after 2h25m33s
chore: build
2026-07-14 18:34:58 +02:00

413 lines
14 KiB
Go

package db
import (
"fmt"
"log"
"slices"
"gorm.io/gorm"
)
func (d *Database) CancelCommandAtomic(commandID int, username, reason string, force bool) (int, error) {
log.Printf("🔒 [CancelAtomic] START - cmd=%d, user=%s, force=%v", commandID, username, force)
var penalty int
err := d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
return err
}
if cmdResult.Username == "" {
return fmt.Errorf("commande non trouvée")
}
log.Printf("📋 [CancelAtomic] Trouvée - status=%s, owner=%s, livreur=%s",
cmdResult.Status, cmdResult.Username, cmdResult.LivreurAssign)
if cmdResult.Username != username {
return fmt.Errorf("commande ne vous appartient pas")
}
nonCancellableStatuses := []string{"livre", "approved", "cancelled", "disabled"}
if slices.Contains(nonCancellableStatuses, cmdResult.Status) {
return fmt.Errorf("impossible d'annuler")
}
isLateCancel := false
if cmdResult.LivreurAssign != "" {
if cmdResult.Status == "en_route" || cmdResult.Status == "arrived" {
isLateCancel = true
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - Statut: %s", cmdResult.Status)
} else if d.CheckCommandETAExistsAndValid(commandID) {
isLateCancel = true
log.Printf("⚠️ [CancelAtomic] Annulation TARDIVE détectée - ETA définie")
} else {
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Statut: %s, Pas d'ETA valide", cmdResult.Status)
}
} else {
log.Printf("✅ [CancelAtomic] Annulation SANS PÉNALITÉ - Aucun livreur assigné")
}
if isLateCancel && !force {
return fmt.Errorf("confirmation requise")
}
cancelMsg := reason
if cancelMsg == "Annulation par le client" {
cancelMsg = ""
}
result := tx.Exec(`
UPDATE commandes
SET status = 'cancelled', cancel_reason = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND status = ? AND username = ?`, cancelMsg, commandID, cmdResult.Status, username)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande déjà modifiée")
}
log.Printf("✅ [CancelAtomic] Statut mis à jour: %s → cancelled", cmdResult.Status)
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)
}
log.Printf("✅ [CancelAtomic] Stock remboursé")
if err := tx.Exec(`
UPDATE clients
SET cancellations_count = COALESCE(cancellations_count, 0) + 1,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, username).Error; err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur incrémentation count: %v", err)
}
if isLateCancel {
log.Printf("⚠️ [CancelAtomic] Annulation tardive confirmée - Application pénalité")
penalty, _ = d.CalculateCancellationPenalty(username)
if err := tx.Exec(`
UPDATE clients
SET amende = amende + ?,
updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, penalty, username).Error; err != nil {
log.Printf("❌ [CancelAtomic] Erreur pénalité: %v", err)
} else {
log.Printf("⚠️ [CancelAtomic] Pénalité: %d appliquée à %s", penalty, username)
}
}
if err := tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, 'cancelled', ?, ?, CURRENT_TIMESTAMP)`,
commandID,
fmt.Sprintf("Annulée par %s - Raison: %s", username, reason),
username,
).Error; err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur log: %v", err)
}
// Nettoyage async après commit
livreur := cmdResult.LivreurAssign
if livreur != "" {
go func() {
if err := d.CleanupCompletedCommandFromQueue(commandID, livreur); err != nil {
log.Printf("⚠️ [CancelAtomic] Erreur cleanup queue: %v", err)
}
}()
}
go func() {
Redis.Del(RedisCtx,
fmt.Sprintf("command:%d", commandID),
fmt.Sprintf("client:%s", username),
fmt.Sprintf("client:%s:commands", username),
)
}()
return nil
})
if err != nil {
return 0, err
}
log.Printf("🎉 [CancelAtomic] SUCCÈS - Commande %d annulée", commandID)
return penalty, nil
}
func (d *Database) CheckCommandETAExistsAndValid(commandID int) bool {
etaKey := fmt.Sprintf("command:eta:%d", commandID)
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
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
}
ttl, err := Redis.TTL(RedisCtx, etaKey).Result()
if err != nil || ttl <= 0 {
log.Printf("⚠️ [CheckETA] ETA expirée pour cmd %d", commandID)
return false
}
log.Printf("✅ [CheckETA] ETA valide trouvée pour cmd %d: %d min (TTL: %v)", commandID, etaMinutes, ttl)
return true
}
func (d *Database) DeleteCommandAtomic(commandID int, deletedBy, role string) error {
return d.GDB.Transaction(func(tx *gorm.DB) error {
var cmdResult struct {
Status string `gorm:"column:status"`
Username string `gorm:"column:username"`
LivreurAssign string `gorm:"column:livreur_assign"`
}
err := tx.Raw(`
SELECT status, username, COALESCE(livreur_assign, '') as livreur_assign
FROM commandes WHERE id = ? FOR UPDATE`, commandID).Scan(&cmdResult).Error
if err != nil {
return err
}
if cmdResult.Username == "" {
return fmt.Errorf("commande non trouvée")
}
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" || cmdResult.Status == "livre"
if !stockAlreadyRestored {
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 {
log.Printf("⚠️ [DeleteAtomic] Erreur remboursement: %v", err)
} else {
log.Printf("✅ [DeleteAtomic] Stock remboursé (statut: %s)", cmdResult.Status)
}
} else {
log.Printf("⏭️ [DeleteAtomic] Stock NON restitué - statut=%s", cmdResult.Status)
}
// ✅ Log suppression
tx.Exec(`
INSERT INTO command_logs (command_id, status, message, author, created_at)
VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
commandID, "deleted",
fmt.Sprintf("Supprimée par %s (%s) - Ancien statut: %s", deletedBy, role, cmdResult.Status),
deletedBy)
if err := tx.Exec(`DELETE FROM command_items WHERE command_id = ?`, commandID).Error; err != nil {
return err
}
result := tx.Exec(`DELETE FROM commandes WHERE id = ?`, commandID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("commande non trouvée")
}
log.Printf("✅ [DeleteAtomic] Supprimée de la DB")
livreur := cmdResult.LivreurAssign
cmdUsername := cmdResult.Username
if livreur != "" {
go d.RemoveCommandFromAllQueues(commandID, livreur)
}
go func() {
Redis.Del(RedisCtx,
fmt.Sprintf("command:%d", commandID),
fmt.Sprintf("client:%s", cmdUsername),
fmt.Sprintf("client:%s:commands", cmdUsername),
)
}()
return nil
})
}
func (d *Database) GetCommandPositionInQueue(livreurUsername string, commandID int) (int, error) {
queueKey := fmt.Sprintf("queue:deliveryman:%s", livreurUsername)
commandIDStr := fmt.Sprintf("%d", commandID)
rank, err := Redis.ZRank(RedisCtx, queueKey, commandIDStr).Result()
if err != nil {
return 0, fmt.Errorf("commande non trouvée dans la queue")
}
return int(rank) + 1, nil
}
func (d *Database) GetCancelledCommands(username string, limit int) ([]map[string]any, error) {
query := `
SELECT id, client_order_id AS client_order_number, username, status, adresse, total_prix::float8 as total_prix, created_at, updated_at, COALESCE(cancel_reason, '') AS cancel_reason
FROM commandes
WHERE status = 'cancelled'`
args := []any{}
if username != "" {
query += " AND username = ?"
args = append(args, username)
}
query += " ORDER BY updated_at DESC"
if limit > 0 {
query += " LIMIT ?"
args = append(args, limit)
}
var commands []map[string]any
if err := d.GDB.Raw(query, args...).Scan(&commands).Error; err != nil {
return nil, fmt.Errorf("erreur récupération: %w", err)
}
return commands, nil
}
// AddClientPenalty ajoute une pénalité à un client
func (d *Database) AddClientPenalty(username string, points int) error {
log.Printf("⚠️ [AddPenalty] Ajout pénalité: %d points pour client %s", points, username)
if points <= 0 {
return fmt.Errorf("points invalides: %d", points)
}
if username == "" {
return fmt.Errorf("username vide")
}
result := d.GDB.Exec(`
UPDATE clients
SET amende = amende + ?, updated_at = CURRENT_TIMESTAMP
WHERE username = ?`, points, username)
if result.Error != nil {
log.Printf("❌ [AddPenalty] Erreur UPDATE: %v", result.Error)
return fmt.Errorf("erreur ajout pénalité: %w", result.Error)
}
if result.RowsAffected == 0 {
return fmt.Errorf("client non trouvé: %s", username)
}
log.Printf("✅ [AddPenalty] Pénalité ajoutée: +%d points pour %s", points, username)
cacheKey := fmt.Sprintf("client:%s", username)
Redis.Del(RedisCtx, cacheKey)
return nil
}
// 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 {
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
}