chore: build
Backend - Build & Lint / build (push) Failing after 31m13s
Frontend Web - Build & Lint / build (push) Failing after 9m49s

This commit is contained in:
Xor290
2026-08-16 13:40:55 +02:00
parent 32a60b4476
commit b8fddc51c2
8 changed files with 348 additions and 2 deletions
+30
View File
@@ -462,6 +462,36 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
return nil return nil
} }
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de SA
// PROPRE commande, tant qu'elle n'est pas encore prise en charge par un
// livreur (statut "en_route") ni terminée. La vérification d'appartenance et
// de statut se fait dans la clause WHERE, atomiquement : impossible de
// modifier la commande d'un autre client ou une commande déjà en route.
func (d *Database) UpdateOwnCommandAddress(commandID int, clientUsername, deliveryAddress string) error {
if err := validateAddress(deliveryAddress); err != nil {
return err
}
result := d.GDB.Exec(`
UPDATE commandes
SET adresse = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND username = ? AND status IN ('pending', 'assigned')`,
deliveryAddress, commandID, clientUsername)
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 introuvable, non modifiable (déjà en livraison ou terminée), ou n'appartenant pas à ce client")
}
d.AddCommandLog(commandID, "address_updated",
fmt.Sprintf("Adresse corrigée par le client %s", clientUsername),
clientUsername)
log.Printf("✅ [UPD_OWN_ADDR] Adresse commande %d corrigée par %s", commandID, clientUsername)
return nil
}
// ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client // ProposeAddressChange propose une nouvelle adresse (admin/cabine) en attente de validation client
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error { func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
if err := validateAddress(proposedAddress); err != nil { if err := validateAddress(proposedAddress); err != nil {
+56
View File
@@ -263,6 +263,62 @@ func RespondToAddressProposal(c *gin.Context) {
}) })
} }
// UpdateOwnCommandAddress permet à un client de corriger l'adresse de sa
// propre commande (ex: suite à un échec de géocodage bloquant l'assignation
// auto). Refusé si la commande est déjà en_route ou terminée (voir requête
// SQL dans db.UpdateOwnCommandAddress).
func UpdateOwnCommandAddress(c *gin.Context) {
database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
userRole := c.GetString("role")
if !utils.CheckRoleClient(c, userRole) {
c.JSON(http.StatusForbidden, gin.H{"error": "Accès refusé"})
return
}
clientUsername, err := safeGetUsername(c)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentification requise"})
return
}
rateLimitKey := fmt.Sprintf("update_own_addr:%s", clientUsername)
if !checkRateLimit(rateLimitKey) {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "Trop de requêtes, réessayez plus tard"})
return
}
commandID, err := strconv.Atoi(c.Param("id"))
if err != nil || commandID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ID de commande invalide"})
return
}
var req struct {
DeliveryAddress string `json:"delivery_address" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"})
return
}
if !geoService.IsValidAddress(req.DeliveryAddress) {
c.JSON(http.StatusBadRequest, gin.H{"error": "Adresse introuvable, vérifiez l'orthographe ou le code postal"})
return
}
if err := database.UpdateOwnCommandAddress(commandID, clientUsername, req.DeliveryAddress); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Printf("✅ [UPD_OWN_ADDR] Commande %d mise à jour par %s", commandID, clientUsername)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Adresse mise à jour",
})
}
func ExportApprovedCommandsCSV(c *gin.Context) { func ExportApprovedCommandsCSV(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
@@ -1,8 +1,10 @@
package handlers package handlers
import ( import (
"encoding/json"
"fmt" "fmt"
"gestion/db" "gestion/db"
"gestion/services"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@@ -30,6 +32,7 @@ const (
// POST /api/v1/deliveries/:id/start // POST /api/v1/deliveries/:id/start
func StartDelivery(c *gin.Context) { func StartDelivery(c *gin.Context) {
database := c.MustGet("database").(*db.Database) database := c.MustGet("database").(*db.Database)
geoService := c.MustGet("geoService").(*services.GeoService)
username, exists := c.Get("username") username, exists := c.Get("username")
if !exists || c.GetString("role") != "livreur" { if !exists || c.GetString("role") != "livreur" {
@@ -114,11 +117,47 @@ func StartDelivery(c *gin.Context) {
} }
} }
} }
if etaMinutes == 0 && req.Latitude != 0 && req.Longitude != 0 { if etaMinutes == 0 {
destLat, _ := command["dest_latitude"].(float64) destLat, _ := command["dest_latitude"].(float64)
destLon, _ := command["dest_longitude"].(float64) destLon, _ := command["dest_longitude"].(float64)
// Fallback 1 : cache Redis (géocodage déjà fait à l'assignation
// mais pas encore persisté en DB — cf. goroutine async dans
// handlers/commands.go AssignCommandToDeliveryman).
if destLat == 0 || destLon == 0 {
destCacheKey := fmt.Sprintf("command:destination:%d", commandID)
if destData, err := db.Redis.Get(db.RedisCtx, destCacheKey).Result(); err == nil && destData != "" {
var coords struct {
Lat float64 `json:"lat"`
Lon float64 `json:"lon"`
}
if err := json.Unmarshal([]byte(destData), &coords); err == nil && coords.Lat != 0 && coords.Lon != 0 {
destLat, destLon = coords.Lat, coords.Lon
}
}
}
// Fallback 2 : géocodage synchrone de l'adresse. Couvre le cas où
// le livreur démarre la livraison avant que la goroutine async
// d'assignation ait fini de géocoder (race condition).
if (destLat == 0 || destLon == 0) && geoService != nil {
if adresse, _ := command["adresse"].(string); adresse != "" {
if location, err := geoService.GeocodeAddress(adresse); err == nil && location != nil {
destLat, destLon = location.Latitude, location.Longitude
database.GDB.Exec(
"UPDATE commandes SET dest_latitude = ?, dest_longitude = ? WHERE id = ?",
destLat, destLon, commandID,
)
}
}
}
if destLat != 0 && destLon != 0 { if destLat != 0 && destLon != 0 {
etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon) etaMinutes = database.CalculateETAForDeliveryman(usernameStr, destLat, destLon)
} else {
// Fallback 3 : aucune coordonnée exploitable — ETA par
// défaut plutôt que pas d'ETA du tout dans le message.
etaMinutes = 30
} }
} }
if etaMinutes > 0 { if etaMinutes > 0 {
+1
View File
@@ -80,6 +80,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
// Approbation livraison // Approbation livraison
cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery) cartGroupV1.POST("/commands/:id/approve", handlers.ApproveDelivery)
cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal) cartGroupV1.POST("/commands/:id/address/respond", handlers.RespondToAddressProposal)
cartGroupV1.PUT("/commands/:id/address", handlers.UpdateOwnCommandAddress)
// ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES // ⭐ NOUVEAU - HISTORIQUE DES COMMANDES TERMINÉES
cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems) cartGroupV1.GET("/my-commands/history/detailed", handlers.GetMyCompletedOrdersWithItems)
cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory) cartGroupV1.GET("/commands/:id/history", handlers.GetOrderHistory)
+27 -1
View File
@@ -83,7 +83,8 @@ func processAutoAssignmentWithPriority(database *db.Database, geoService *servic
} }
// Tenter l'assignation // Tenter l'assignation
success := tryAssignCommandWithPriority(database, geoService, commandID, address, priority, int(waitingTime.Minutes())) username, _ := cmd["username"].(string)
success := tryAssignCommandWithPriority(database, geoService, commandID, username, address, priority, int(waitingTime.Minutes()))
if success { if success {
assignedCount++ assignedCount++
} else { } else {
@@ -103,6 +104,7 @@ func tryAssignCommandWithPriority(
database *db.Database, database *db.Database,
geoService *services.GeoService, geoService *services.GeoService,
commandID int, commandID int,
username string,
address string, address string,
priority int, priority int,
waitingMinutes int, waitingMinutes int,
@@ -114,6 +116,7 @@ func tryAssignCommandWithPriority(
location, err := geoService.GeocodeAddress(address) location, err := geoService.GeocodeAddress(address)
if err != nil { if err != nil {
log.Printf("❌ [CRON] Cmd %d - Géocodage échoué: %v", commandID, err) log.Printf("❌ [CRON] Cmd %d - Géocodage échoué: %v", commandID, err)
notifyGeocodeFailure(database, username, commandID, address)
return false return false
} }
@@ -198,3 +201,26 @@ func tryAssignCommandWithPriority(
return true return true
} }
// notifyGeocodeFailure avertit le client que l'adresse de sa commande n'a pas
// pu être localisée, pour qu'il puisse la corriger. Le cron retente chaque
// minute tant que la commande reste pending : un cooldown Redis d'une heure
// évite de spammer le client à chaque cycle avec la même erreur.
func notifyGeocodeFailure(database *db.Database, username string, commandID int, address string) {
if username == "" {
return
}
cooldownKey := fmt.Sprintf("notif:cooldown:geocode_fail:%d", commandID)
set, err := db.Redis.SetNX(db.RedisCtx, cooldownKey, "1", time.Hour).Result()
if err != nil || !set {
return
}
clientOrderID := database.GetClientOrderID(commandID)
msg := fmt.Sprintf(
"Ta commande #%d ne peut pas être assignée : l'adresse \"%s\" n'a pas été trouvée. Merci de vérifier et corriger l'adresse de livraison.",
clientOrderID, address,
)
if err := database.NotifyClient(username, commandID, "address_error", msg); err != nil {
log.Printf("⚠️ [CRON] Cmd %d - Erreur notification échec géocodage: %v", commandID, err)
}
}
+49
View File
@@ -1440,6 +1440,55 @@ export const cancelCommand = async (
} }
}; };
// Le client corrige lui-même l'adresse de sa commande (refusé si déjà en
// livraison ou terminée, cf. UpdateOwnCommandAddress côté backend).
export const updateOwnCommandAddress = async (
commandId: number,
deliveryAddress: string,
): Promise<{ success: boolean; message: string }> => {
const token = sessionStorage.getItem("token");
if (!token) {
return { success: false, message: "Session invalide" };
}
try {
const response = await fetch(
`${API_URL}/commands/${commandId}/address`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ delivery_address: deliveryAddress }),
},
);
const data = await safeJson(response);
if (!response.ok) {
return {
success: false,
message: data.error || "Erreur lors de la mise à jour",
};
}
return {
success: true,
message: data.message || "Adresse mise à jour",
};
} catch (error) {
return {
success: false,
message:
error instanceof Error
? error.message
: "Erreur lors de la mise à jour de l'adresse",
};
}
};
/** /**
* GET MY CANCELLATION HISTORY - Historique des annulations * GET MY CANCELLATION HISTORY - Historique des annulations
* GET /api/v1/my-cancellation-history * GET /api/v1/my-cancellation-history
@@ -489,6 +489,40 @@
font-weight: 600; font-weight: 600;
color: var(--text); color: var(--text);
} }
.address-edit-toggle {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.4rem;
background: none;
border: none;
color: var(--primary);
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
padding: 0;
}
.address-edit-toggle:hover {
text-decoration: underline;
}
.address-edit {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.address-edit-input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.85rem;
background: var(--bg-secondary, #fff);
color: var(--text);
}
.address-edit-actions {
display: flex;
gap: 0.5rem;
}
.contact { .contact {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
@@ -14,6 +14,7 @@ import {
getOrderETA, getOrderETA,
confirmReception, confirmReception,
cancelCommand, cancelCommand,
updateOwnCommandAddress,
isUserAuthenticated, isUserAuthenticated,
getPublicSettings, getPublicSettings,
} from "../../api/api"; } from "../../api/api";
@@ -51,6 +52,7 @@ import {
faWind, faWind,
faChevronUp, faChevronUp,
faChevronDown, faChevronDown,
faPen,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
interface OrderWithTracking extends OrderDetail { interface OrderWithTracking extends OrderDetail {
@@ -282,6 +284,11 @@ function SuiviLivraison() {
const [showCancelDialog, setShowCancelDialog] = useState(false); const [showCancelDialog, setShowCancelDialog] = useState(false);
const [orderToCancel, setOrderToCancel] = useState<number | null>(null); const [orderToCancel, setOrderToCancel] = useState<number | null>(null);
const [cancelReason, setCancelReason] = useState(""); const [cancelReason, setCancelReason] = useState("");
const [editingAddressOrder, setEditingAddressOrder] = useState<
number | null
>(null);
const [newAddressValue, setNewAddressValue] = useState("");
const [savingAddress, setSavingAddress] = useState(false);
const [showPenaltyWarning, setShowPenaltyWarning] = useState(false); const [showPenaltyWarning, setShowPenaltyWarning] = useState(false);
const [penaltyWarningData, setPenaltyWarningData] = const [penaltyWarningData, setPenaltyWarningData] =
useState<CancelCommandResponse | null>(null); useState<CancelCommandResponse | null>(null);
@@ -383,6 +390,28 @@ function SuiviLivraison() {
} }
}; };
const handleUpdateAddress = async (orderId: number) => {
setSavingAddress(true);
try {
const res = await updateOwnCommandAddress(
orderId,
newAddressValue,
);
if (res.success) {
showToast(res.message || "Adresse mise à jour", "success");
setEditingAddressOrder(null);
setNewAddressValue("");
loadOrders();
} else {
showToast(res.message || "Erreur", "error");
}
} catch {
showToast("Erreur mise à jour adresse", "error");
} finally {
setSavingAddress(false);
}
};
const showToast = ( const showToast = (
message: string, message: string,
type: "success" | "error" | "warning" | "info", type: "success" | "error" | "warning" | "info",
@@ -889,6 +918,88 @@ function SuiviLivraison() {
order, order,
)} )}
</p> </p>
{(statusLow ===
"pending" ||
statusLow ===
"assigned") &&
(editingAddressOrder ===
order.id ? (
<div className="address-edit">
<input
type="text"
className="address-edit-input"
value={
newAddressValue
}
onChange={(
e,
) =>
setNewAddressValue(
e
.target
.value,
)
}
placeholder="Ex: 24 Rue Docteur Brindeau, 44000 Nantes"
/>
<div className="address-edit-actions">
<button
type="button"
className="btn-secondary"
onClick={() => {
setEditingAddressOrder(
null,
);
setNewAddressValue(
"",
);
}}
>
Annuler
</button>
<button
type="button"
className="btn-primary"
disabled={
savingAddress ||
!newAddressValue.trim()
}
onClick={() =>
handleUpdateAddress(
order.id,
)
}
>
{savingAddress
? "Enregistrement..."
: "Enregistrer"}
</button>
</div>
</div>
) : (
<button
type="button"
className="address-edit-toggle"
onClick={() => {
setNewAddressValue(
getDeliveryAddress(
order,
),
);
setEditingAddressOrder(
order.id,
);
}}
>
<FontAwesomeIcon
icon={
faPen
}
/>{" "}
Modifier
l'adresse
</button>
))}
{(() => { {(() => {
const info = const info =
getClientInfo( getClientInfo(