chore: build

This commit is contained in:
2026-06-13 14:23:23 +02:00
parent 6f699bea6a
commit 446b15d29c
13 changed files with 137 additions and 34 deletions
+43 -2
View File
@@ -31,7 +31,7 @@ func (d *Database) GetProductPrice(name, category string, quantity float64) (flo
func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) { func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, error) {
var baskets []models.Panier var baskets []models.Panier
err := d.GDB.Raw(` err := d.GDB.Raw(`
SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.created_at, SELECT b.id, b.username, b.product_id, b.quantity, b.price, b.is_reward, b.created_at,
p.name as product_name, p.category, p.description p.name as product_name, p.category, p.description
FROM baskets b FROM baskets b
INNER JOIN products p ON b.product_id = p.id INNER JOIN products p ON b.product_id = p.id
@@ -43,6 +43,47 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
return baskets, nil return baskets, nil
} }
// AddRewardToBasket ajoute un produit récompense au panier (prix = 0, is_reward = true).
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
func (d *Database) AddRewardToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
var basket models.Panier
err := d.GDB.Transaction(func(tx *gorm.DB) error {
// Vérifier que le produit existe
var productName string
if err := tx.Raw(`SELECT name FROM products WHERE id = ?`, productID).Scan(&productName).Error; err != nil || productName == "" {
return fmt.Errorf("produit récompense introuvable (id=%d)", productID)
}
// Supprimer tout article récompense existant pour ce produit (remplacement)
tx.Exec(`DELETE FROM baskets WHERE username = ? AND product_id = ? AND is_reward = true`, username, productID)
// Insérer avec prix 0 et is_reward = true
return tx.Raw(`
INSERT INTO baskets (username, product_id, quantity, price, is_reward, created_at)
VALUES (?, ?, ?, 0, true, CURRENT_TIMESTAMP)
RETURNING id, username, product_id, quantity, price, is_reward, created_at`,
username, productID, quantity).Scan(&basket).Error
})
if err != nil {
return nil, err
}
return &basket, nil
}
// HasOnlyRewardItems retourne true si le panier ne contient que des articles récompense.
func (d *Database) HasOnlyRewardItems(username string) (bool, error) {
var counts struct {
Total int `gorm:"column:total"`
Normal int `gorm:"column:normal"`
}
err := d.GDB.Raw(`
SELECT COUNT(*) as total,
COUNT(*) FILTER (WHERE is_reward = false) as normal
FROM baskets WHERE username = ?`, username).Scan(&counts).Error
if err != nil {
return false, err
}
return counts.Total > 0 && counts.Normal == 0, nil
}
// AddToBasket vérifie le stock disponible et ajoute l'article au panier. // AddToBasket vérifie le stock disponible et ajoute l'article au panier.
// Le stock n'est pas décrémenté ici — il l'est uniquement au checkout. // Le stock n'est pas décrémenté ici — il l'est uniquement au checkout.
func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) { func (d *Database) AddToBasket(username string, productID int, quantity float64) (*models.Panier, error) {
@@ -186,7 +227,7 @@ func (d *Database) GetReservedQuantityInBaskets(productID int) (float64, error)
func (d *Database) GetBasketItems(username string) ([]map[string]any, error) { func (d *Database) GetBasketItems(username string) ([]map[string]any, error) {
var items []map[string]any var items []map[string]any
if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price FROM baskets WHERE username = ?`, if err := d.GDB.Raw(`SELECT product_id, quantity::float8 as quantity, price::float8 as price, is_reward FROM baskets WHERE username = ?`,
username).Scan(&items).Error; err != nil { username).Scan(&items).Error; err != nil {
return nil, err return nil, err
} }
+5
View File
@@ -120,6 +120,11 @@ func InitDB() *Database {
log.Fatalf("❌ Erreur migration baskets.quantity: %v", err) log.Fatalf("❌ Erreur migration baskets.quantity: %v", err)
} }
// Migration: baskets.is_reward — marquer les articles issus d'une récompense points
if _, err = database.Exec(`ALTER TABLE baskets ADD COLUMN IF NOT EXISTS is_reward BOOLEAN NOT NULL DEFAULT FALSE`); err != nil {
log.Fatalf("❌ Erreur migration baskets.is_reward: %v", err)
}
// Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires // Migration: command_items.quantite INTEGER → NUMERIC(10,3) pour supporter les quantités fractionnaires
if _, err = database.Exec(` if _, err = database.Exec(`
DO $$ DO $$
@@ -123,6 +123,7 @@ func GetMyCommandsWithTracking(c *gin.Context) {
"status_message": getStatusMessage(cmd["status"].(string)), "status_message": getStatusMessage(cmd["status"].(string)),
"adresse": cmd["adresse"], "adresse": cmd["adresse"],
"total_prix": cmd["total_prix"], "total_prix": cmd["total_prix"],
"referral_used": cmd["referral_used"],
"created_at": cmd["created_at"], "created_at": cmd["created_at"],
"livreur": livreurInfo, "livreur": livreurInfo,
"eta": etaData, "eta": etaData,
+14
View File
@@ -302,6 +302,20 @@ func ValidateBasket(c *gin.Context) {
return return
} }
// Vérifier qu'il y a au moins un article normal (non-récompense)
hasNormalItem := false
for _, item := range items {
if isReward, ok := item["is_reward"].(bool); !ok || !isReward {
hasNormalItem = true
break
}
}
if !hasNormalItem {
log.Printf("❌ [CHECKOUT] Panier contient uniquement des récompenses pour %s", usernameStr)
c.JSON(http.StatusBadRequest, gin.H{"error": "Vous devez commander au moins un produit de la boutique pour bénéficier de votre récompense"})
return
}
log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items)) log.Printf("🛒 [CHECKOUT] Panier: %d articles", len(items))
// ============================================ // ============================================
+17
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"gestion/db" "gestion/db"
"gestion/utils" "gestion/utils"
"log"
"net/http" "net/http"
"strings" "strings"
@@ -191,10 +192,26 @@ func ClaimMyReward(c *gin.Context) {
return return
} }
// Ajouter le produit récompense au panier si configuré
productAdded := false
var productName string
if reward.RewardProductID > 0 && reward.RewardQuantity > 0 {
qty := reward.RewardQuantity
if item, addErr := database.AddRewardToBasket(username, reward.RewardProductID, qty); addErr == nil {
productAdded = true
productName = item.ProductName
log.Printf("✅ [CLAIM] Produit récompense id=%d (%.2f) ajouté au panier de %s", reward.RewardProductID, qty, username)
} else {
log.Printf("⚠️ [CLAIM] Impossible d'ajouter produit récompense: %v", addErr)
}
}
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"description": reward.Description, "description": reward.Description,
"remaining_rewards": remaining, "remaining_rewards": remaining,
"product_added": productAdded,
"product_name": productName,
}) })
} }
+1
View File
@@ -11,6 +11,7 @@ type Panier struct {
Description string `json:"description"` Description string `json:"description"`
Quantity float64 `json:"quantity"` Quantity float64 `json:"quantity"`
Price float64 `json:"price"` Price float64 `json:"price"`
IsReward bool `json:"is_reward"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
} }
+6 -4
View File
@@ -24,10 +24,12 @@ type RewardCategoryConfig struct {
// PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés // PointsReward représente la récompense débloquée à partir d'un seuil de points cumulés
type PointsReward struct { type PointsReward struct {
Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20) Threshold int `json:"threshold"` // points cumulés nécessaires (ex: 20)
Type string `json:"type"` // "free_product" | "half_price_product" | "custom" Type string `json:"type"` // "free_product" | "half_price_product" | "custom"
Description string `json:"description"` // description libre affichée au client Description string `json:"description"` // description libre affichée au client
CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles CategoryConfigs []RewardCategoryConfig `json:"category_configs"` // catégories + produits éligibles
RewardProductID int `json:"reward_product_id"` // ID produit ajouté au panier (0 = désactivé)
RewardQuantity float64 `json:"reward_quantity"` // quantité du produit récompense
} }
// DaySchedule représente les horaires de livraison pour un jour de la semaine // DaySchedule représente les horaires de livraison pour un jour de la semaine
+7
View File
@@ -718,6 +718,9 @@ export const createCheckout = async (checkoutData: CheckoutData) => {
pay_currency: data.pay_currency as string | undefined, pay_currency: data.pay_currency as string | undefined,
price_amount: data.price_amount as number | undefined, price_amount: data.price_amount as number | undefined,
price_currency: data.price_currency as string | undefined, price_currency: data.price_currency as string | undefined,
referral_used: data.referral_used as number | undefined,
referral_balance: data.referral_balance as number | undefined,
client_order_number: data.client_order_number as number | undefined,
}; };
} catch (error) { } catch (error) {
console.error("❌ [CHECKOUT] Erreur:", error); console.error("❌ [CHECKOUT] Erreur:", error);
@@ -2194,6 +2197,8 @@ export const claimMyReward = async (poolKey: string): Promise<{
success: boolean; success: boolean;
description?: string; description?: string;
remaining_rewards?: number; remaining_rewards?: number;
product_added?: boolean;
product_name?: string;
error?: string; error?: string;
}> => { }> => {
const token = getAuthToken(); const token = getAuthToken();
@@ -2213,6 +2218,8 @@ export const claimMyReward = async (poolKey: string): Promise<{
success: true, success: true,
description: data.description, description: data.description,
remaining_rewards: data.remaining_rewards, remaining_rewards: data.remaining_rewards,
product_added: data.product_added,
product_name: data.product_name,
}; };
} catch { } catch {
return { success: false, error: "Erreur de connexion" }; return { success: false, error: "Erreur de connexion" };
+1
View File
@@ -110,6 +110,7 @@ export interface CartItem {
quantity: number; quantity: number;
category: string; category: string;
image?: string; image?: string;
is_reward?: boolean;
} }
/** /**
+15 -2
View File
@@ -176,9 +176,22 @@ function Cart() {
{/* Infos */} {/* Infos */}
<div className="cart-row-info"> <div className="cart-row-info">
<p className="cart-row-name">{item.name_product}</p> <p className="cart-row-name">
{item.name_product}
{item.is_reward && (
<span style={{ marginLeft: "6px", fontSize: "0.7rem", fontWeight: 700, color: "#f59e0b", background: "rgba(245,158,11,0.12)", borderRadius: "4px", padding: "1px 6px" }}>
🎁 Récompense
</span>
)}
</p>
<p className="cart-row-qty">{item.quantity}g</p> <p className="cart-row-qty">{item.quantity}g</p>
<p className="cart-row-price">{item.price.toFixed(2)} </p> <p className="cart-row-price">
{item.is_reward ? (
<span style={{ color: "#10b981", fontWeight: 700 }}>Offert</span>
) : (
`${item.price.toFixed(2)}`
)}
</p>
</div> </div>
{/* Bouton supprimer */} {/* Bouton supprimer */}
+2 -2
View File
@@ -355,13 +355,13 @@ function Checkout() {
// ✅ Préparer les données pour le modal // ✅ Préparer les données pour le modal
setConfirmationData({ setConfirmationData({
command_id, command_id,
client_order_number: (response as Record<string, unknown>).client_order_number as number | undefined, client_order_number: response.client_order_number,
assigned_to, assigned_to,
queue_info, queue_info,
delivery_address: delivery_address || address, delivery_address: delivery_address || address,
arrivalTime, arrivalTime,
total: frontendTotal, total: frontendTotal,
referral_used: (response as Record<string, unknown>).referral_used as number | undefined, referral_used: response.referral_used,
clientInfo: { clientInfo: {
first_name: firstName, first_name: firstName,
last_name: lastName, last_name: lastName,
@@ -176,7 +176,10 @@ function ConsultationHistorique() {
const res = await claimMyReward(poolKey); const res = await claimMyReward(poolKey);
setClaimingPool(null); setClaimingPool(null);
if (res.success) { if (res.success) {
setClaimFeedback({ pool: poolKey, type: "success", text: res.description || "Récompense réclamée !" }); const text = res.product_added && res.product_name
? `🎁 ${res.product_name} ajouté à votre panier ! Commandez au moins un produit pour en profiter.`
: res.description || "Récompense réclamée !";
setClaimFeedback({ pool: poolKey, type: "success", text });
getMyPointsRewards().then((r) => { if (r.success) setPointsRewards(r); }); getMyPointsRewards().then((r) => { if (r.success) setPointsRewards(r); });
} else { } else {
setClaimFeedback({ pool: poolKey, type: "error", text: res.error || "Erreur" }); setClaimFeedback({ pool: poolKey, type: "error", text: res.error || "Erreur" });
+21 -23
View File
@@ -73,30 +73,21 @@ interface ToastMessage {
* Somme des prix individuels (pas de multiplication) * Somme des prix individuels (pas de multiplication)
*/ */
const getTotalAmount = (order: OrderWithTracking): number => { const getTotalAmount = (order: OrderWithTracking): number => {
// 1. Priorité: champ total stocké en DB let gross = 0;
if (typeof order.total === "number" && order.total > 0) { if (typeof order.total === "number" && order.total > 0) {
return order.total; gross = order.total;
} } else if (typeof order.total_prix === "number" && order.total_prix > 0) {
gross = order.total_prix;
// 2. Fallback: total_prix } else if (order.items && order.items.length > 0) {
if (typeof order.total_prix === "number" && order.total_prix > 0) { gross = order.items.reduce((sum, item) => {
return order.total_prix;
}
// 3. Calcul depuis items (comme dans Checkout: somme des prix)
if (order.items && order.items.length > 0) {
const calculatedTotal = order.items.reduce((sum, item) => {
const itemPrice = item.prix || item.price || 0; const itemPrice = item.prix || item.price || 0;
return sum + itemPrice; // ✅ Somme simple (pas de × quantity) return sum + itemPrice;
}, 0); }, 0);
console.log(
`💰 [getTotalAmount] Commande ${order.id}: ${calculatedTotal.toFixed(2)}`,
);
return calculatedTotal;
} }
return 0; const referralUsed = typeof order.referral_used === "number" ? order.referral_used : 0;
return Math.max(0, gross - referralUsed);
}; };
const getClientInfo = (order: OrderWithTracking) => { const getClientInfo = (order: OrderWithTracking) => {
@@ -933,14 +924,21 @@ function SuiviLivraison() {
/>{" "} />{" "}
Montant total Montant total
</h4> </h4>
{(order.referral_used ?? 0) > 0 && (
<p style={{ margin: "0 0 2px", fontSize: "0.85rem", color: "var(--text-muted)" }}>
Brut : {(order.total_prix ?? 0).toFixed(2)}
</p>
)}
<p className="total-amount"> <p className="total-amount">
<strong> <strong>
{getTotalAmount( {getTotalAmount(order).toFixed(2)}
order,
).toFixed(2)}{" "}
</strong> </strong>
</p> </p>
{(order.referral_used ?? 0) > 0 && (
<p style={{ margin: "4px 0 0", fontSize: "0.82rem", color: "#10b981", fontWeight: 500 }}>
dont {(order.referral_used!).toFixed(2)} parrainage déduit
</p>
)}
</div> </div>
<div className="detail-section"> <div className="detail-section">