chore: build
This commit is contained in:
@@ -7,6 +7,25 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetActiveProductPrice retourne le prix catalogue actif pour un produit et
|
||||
// une quantité donnés (palier le plus proche ≤ quantity, cf. même requête que
|
||||
// AddToBasket) — utilisé pour calculer le prix effectif d'une récompense
|
||||
// "half_price_product" (50% de ce prix).
|
||||
func (d *Database) GetActiveProductPrice(productID int, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
err := d.GDB.Raw(`
|
||||
SELECT price FROM product_prices
|
||||
WHERE product_id = ? AND quantity <= ? AND active_price = true
|
||||
ORDER BY quantity DESC LIMIT 1`,
|
||||
productID, quantity).Scan(&result).Error
|
||||
if err != nil || result.Price == 0 {
|
||||
return 0, fmt.Errorf("prix introuvable pour product_id=%d qty=%.3f", productID, quantity)
|
||||
}
|
||||
return result.Price, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetProductPrice(name, category string, quantity float64) (float64, error) {
|
||||
var result struct {
|
||||
Price float64 `gorm:"column:price"`
|
||||
@@ -42,7 +61,9 @@ func (d *Database) GetAllProductsInBasket(username string) ([]models.Panier, err
|
||||
return baskets, nil
|
||||
}
|
||||
|
||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (prix = 0, is_reward = true).
|
||||
// AddRewardsToBasket ajoute plusieurs produits récompense au panier (is_reward = true),
|
||||
// au prix fourni par l'appelant dans chaque RewardItem.Price (0 pour un produit offert,
|
||||
// ou le prix effectif déjà calculé pour une remise — voir handlers/points.go).
|
||||
// Supprime les anciens items récompense avant d'insérer les nouveaux.
|
||||
// Pas de vérification de stock — les récompenses sont gérées par l'admin.
|
||||
func (d *Database) AddRewardsToBasket(username string, items []models.RewardItem, poolKey string) ([]models.Panier, error) {
|
||||
@@ -78,9 +99,9 @@ func addRewardsToBasketTx(tx *gorm.DB, username string, items []models.RewardIte
|
||||
var basket models.Panier
|
||||
if err := tx.Raw(`
|
||||
INSERT INTO baskets (username, product_id, quantity, price, is_reward, reward_pool_key, created_at)
|
||||
VALUES (?, ?, ?, 0, true, ?, CURRENT_TIMESTAMP)
|
||||
VALUES (?, ?, ?, ?, true, ?, CURRENT_TIMESTAMP)
|
||||
RETURNING id, username, product_id, quantity, price, is_reward, reward_pool_key, created_at`,
|
||||
username, item.ProductID, item.Quantity, poolKey).Scan(&basket).Error; err != nil {
|
||||
username, item.ProductID, item.Quantity, item.Price, poolKey).Scan(&basket).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baskets = append(baskets, basket)
|
||||
|
||||
@@ -462,6 +462,36 @@ func (d *Database) UpdateCommandAddress(commandID int, deliveryAddress string) e
|
||||
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
|
||||
func (d *Database) ProposeAddressChange(commandID int, proposedAddress, proposedBy string) error {
|
||||
if err := validateAddress(proposedAddress); err != nil {
|
||||
@@ -625,7 +655,7 @@ func (d *Database) ValidateDeliveryAtomic(commandID int, adminUsername string) (
|
||||
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"}
|
||||
validStatuses := []string{"assigned", "en_route", "arrived", "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)
|
||||
@@ -862,13 +892,25 @@ func (d *Database) ApproveDeliveryAtomicByStaff(commandID int, staffUsername str
|
||||
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)
|
||||
// Historiquement restreint à "livre" seul (cf. commentaire de
|
||||
// TestApproveDeliveryAtomicByStaff dans les tests) — élargi après un
|
||||
// incident réel où une vérification GPS en amont (coordonnées de
|
||||
// destination périmées après un changement d'adresse, cf.
|
||||
// updateCommandDestinationCoords) a bloqué la transition du livreur
|
||||
// vers "livre" : la commande restait alors coincée, sans qu'admin ni
|
||||
// cabine ne puissent confirmer la réception. On accepte désormais tout
|
||||
// statut non terminal ("arrived" inclus), à l'image de
|
||||
// ValidateDeliveryAtomic (qui accepte déjà pending/assigned/en_route),
|
||||
// pour que le staff garde toujours un moyen de débloquer une commande
|
||||
// légitime indépendamment d'un blocage en amont côté livreur.
|
||||
validStatuses := []string{"pending", "assigned", "en_route", "arrived", "livre"}
|
||||
if !slices.Contains(validStatuses, cmd.Status) {
|
||||
return fmt.Errorf("statut invalide pour confirmation de réception: %s", cmd.Status)
|
||||
}
|
||||
|
||||
result := tx.Exec(`
|
||||
UPDATE commandes SET status = 'approved', updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND status = 'livre'`, commandID)
|
||||
WHERE id = ? AND status = ?`, commandID, cmd.Status)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour statut: %w", result.Error)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"gestion/models"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// CreateProduct crée un nouveau produit avec ses prix
|
||||
@@ -262,14 +264,27 @@ func (d *Database) UpdateProduct(productID int, name, category, description, uni
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProductStock fixe le stock à une valeur absolue. Le verrou FOR UPDATE
|
||||
// sérialise cette écriture avec les décréments du checkout (db_commands.go) :
|
||||
// sans lui, une modification admin pourrait écraser silencieusement le
|
||||
// décrément d'une commande passée au même instant sur le même produit.
|
||||
func (d *Database) SetProductStock(productID int, stock float64) error {
|
||||
result := d.GDB.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
|
||||
stock, time.Now(), productID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return fmt.Errorf("produit non trouvé")
|
||||
err := d.GDB.Transaction(func(tx *gorm.DB) error {
|
||||
var exists int
|
||||
if err := tx.Raw(`SELECT 1 FROM products WHERE id = ? FOR UPDATE`, productID).Scan(&exists).Error; err != nil {
|
||||
return fmt.Errorf("erreur verrouillage produit: %w", err)
|
||||
}
|
||||
if exists == 0 {
|
||||
return fmt.Errorf("produit non trouvé")
|
||||
}
|
||||
if err := tx.Exec(`UPDATE products SET stock = ?, updated_at = ? WHERE id = ?`,
|
||||
stock, time.Now(), productID).Error; err != nil {
|
||||
return fmt.Errorf("erreur mise à jour stock: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -37,11 +37,14 @@ func (d *Database) ReadResetAt(key string) time.Time {
|
||||
|
||||
// ── Construction des clauses WHERE (filtrage par reset) ────────────────────
|
||||
|
||||
// statusFilterClause construit "<baseStatus> [AND created_at >= ?]" et renvoie
|
||||
// la clause ainsi que les arguments à binder, dans l'ordre.
|
||||
func statusFilterClause(baseStatus string, resetAt time.Time) (string, []interface{}) {
|
||||
// statusFilterClause construit "<baseStatus> [AND <dateColumn> >= ?]" et
|
||||
// renvoie la clause ainsi que les arguments à binder, dans l'ordre.
|
||||
// dateColumn doit être qualifié par l'alias de table (ex: "c.created_at") dès
|
||||
// que la requête appelante fait une jointure où plusieurs tables possèdent une
|
||||
// colonne created_at, sous peine d'erreur Postgres "ambiguous column".
|
||||
func statusFilterClause(baseStatus string, resetAt time.Time, dateColumn string) (string, []interface{}) {
|
||||
if !resetAt.IsZero() {
|
||||
return baseStatus + " AND created_at >= ?", []interface{}{resetAt.Format(time.RFC3339)}
|
||||
return baseStatus + " AND " + dateColumn + " >= ?", []interface{}{resetAt.Format(time.RFC3339)}
|
||||
}
|
||||
return baseStatus, nil
|
||||
}
|
||||
@@ -92,7 +95,7 @@ func (d *Database) LoadAdminStatsFilters() AdminStatsFilters {
|
||||
// ── Commandes par jour de la semaine (non annulées) ─────────────────────────
|
||||
|
||||
func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt)
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
query := `
|
||||
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
||||
FROM commandes
|
||||
@@ -106,7 +109,7 @@ func (d *Database) OrderPerDaysPerWeeks(wdRows *[]models.WeekdayRow, resetAt tim
|
||||
// ── Commandes par jour sur 30 jours ──────────────────────────────────────────
|
||||
|
||||
func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt)
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
query := `
|
||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||
FROM commandes
|
||||
@@ -121,7 +124,7 @@ func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time
|
||||
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────────
|
||||
|
||||
func (d *Database) RevenueByDayLast30(dayRevRows *[]models.DayRevenueRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("status = 'approved'", resetAt)
|
||||
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
|
||||
query := `
|
||||
SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
@@ -142,11 +145,19 @@ type DailyMonthStatRow struct {
|
||||
Quantity float64
|
||||
}
|
||||
|
||||
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetAt time.Time) error {
|
||||
// StatsByDayForMonth applique resetCommandes au comptage (count) et à la
|
||||
// quantité (quantity, qui reflète le volume de commandes comme count), et
|
||||
// resetRevenus au revenu (revenue) — chaque métrique doit respecter la même
|
||||
// section de reset que son équivalent dans le résumé global (TotalOrders /
|
||||
// TotalRevenue), sous peine d'afficher des chiffres incohérents entre eux
|
||||
// après une réinitialisation partielle.
|
||||
func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetCommandes time.Time, resetRevenus time.Time) error {
|
||||
start := time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location())
|
||||
end := start.AddDate(0, 1, 0)
|
||||
|
||||
where, whereArgs := statusFilterClause("status != 'cancelled'", resetAt)
|
||||
whereCount, argsCount := statusFilterClause("status != 'cancelled'", resetCommandes, "created_at")
|
||||
whereRevenue, argsRevenue := statusFilterClause("status = 'approved'", resetRevenus, "created_at")
|
||||
whereQuantity, argsQuantity := statusFilterClause("c.status != 'cancelled'", resetCommandes, "c.created_at")
|
||||
|
||||
query := `
|
||||
SELECT
|
||||
@@ -158,7 +169,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
|
||||
SELECT DATE(created_at) AS day, COUNT(*) AS count
|
||||
FROM commandes
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND ` + where + `
|
||||
AND ` + whereCount + `
|
||||
GROUP BY DATE(created_at)
|
||||
) d
|
||||
LEFT JOIN (
|
||||
@@ -166,7 +177,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
|
||||
COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue
|
||||
FROM commandes
|
||||
WHERE created_at >= ? AND created_at < ?
|
||||
AND status = 'approved'
|
||||
AND ` + whereRevenue + `
|
||||
GROUP BY DATE(created_at)
|
||||
) rv ON rv.day = d.day
|
||||
LEFT JOIN (
|
||||
@@ -174,18 +185,20 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
|
||||
FROM commandes c
|
||||
JOIN command_items ci ON ci.command_id = c.id
|
||||
WHERE c.created_at >= ? AND c.created_at < ?
|
||||
AND c.status != 'cancelled'
|
||||
AND ` + whereQuantity + `
|
||||
GROUP BY DATE(c.created_at)
|
||||
) qt ON qt.day = d.day
|
||||
ORDER BY d.day
|
||||
`
|
||||
|
||||
// Ordre des "?" dans la requête : (start, end, [reset]) pour le bloc "d",
|
||||
// puis (start, end) pour "rv", puis (start, end) pour "qt".
|
||||
// Ordre des "?" dans la requête : (start, end, [resetCommandes]) pour "d",
|
||||
// puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt".
|
||||
args := []interface{}{start, end}
|
||||
args = append(args, whereArgs...)
|
||||
args = append(args, argsCount...)
|
||||
args = append(args, start, end)
|
||||
args = append(args, argsRevenue...)
|
||||
args = append(args, start, end)
|
||||
args = append(args, argsQuantity...)
|
||||
|
||||
return d.GDB.Raw(query, args...).Scan(rows).Error
|
||||
}
|
||||
@@ -195,7 +208,7 @@ func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time
|
||||
// cohérent avec TotalRevenue/RevenueByDayLast30, pour ne pas compter comme
|
||||
// "revenu" une commande encore en cours qui pourrait être annulée).
|
||||
func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt)
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
query := `
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM created_at)::int AS hour,
|
||||
@@ -215,7 +228,7 @@ func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt ti
|
||||
// commandes reflètent l'activité (non annulées), le revenu ne compte que les
|
||||
// commandes approuvées (revenu confirmé, cohérent avec le résumé global).
|
||||
func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time, limit int) error {
|
||||
where, args := statusFilterClause("c.status != 'cancelled'", resetAt)
|
||||
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
|
||||
args = append(args, limit)
|
||||
query := `
|
||||
SELECT
|
||||
@@ -245,7 +258,7 @@ func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time,
|
||||
// QuantityBreakdown : quantité/nombre de commandes reflètent l'activité (non
|
||||
// annulées), le revenu ne compte que les commandes approuvées (revenu confirmé).
|
||||
func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, resetAt time.Time) error {
|
||||
where, args := statusFilterClause("c.status != 'cancelled'", resetAt)
|
||||
where, args := statusFilterClause("c.status != 'cancelled'", resetAt, "c.created_at")
|
||||
query := `
|
||||
SELECT
|
||||
ci.product_id,
|
||||
@@ -351,7 +364,7 @@ func (d *Database) DailyOrdersCount() (int64, error) {
|
||||
|
||||
// TotalOrders renvoie le nombre total de commandes filtré par le reset "commandes".
|
||||
func (d *Database) TotalOrders(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt)
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
var total int64
|
||||
err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error
|
||||
return total, err
|
||||
@@ -359,7 +372,7 @@ func (d *Database) TotalOrders(resetAt time.Time) (int64, error) {
|
||||
|
||||
// TotalRevenue renvoie le revenu total (commandes approuvées) filtré par le reset "revenus".
|
||||
func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
|
||||
where, args := statusFilterClause("status = 'approved'", resetAt)
|
||||
where, args := statusFilterClause("status = 'approved'", resetAt, "created_at")
|
||||
var total float64
|
||||
err := d.GDB.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE `+where, args...).
|
||||
Scan(&total).Error
|
||||
@@ -368,7 +381,7 @@ func (d *Database) TotalRevenue(resetAt time.Time) (float64, error) {
|
||||
|
||||
// ActiveDaysLast30 renvoie le nombre de jours distincts ayant eu au moins une commande sur 30 jours.
|
||||
func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt)
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
var activeDays int64
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT DATE(created_at))
|
||||
@@ -380,7 +393,7 @@ func (d *Database) ActiveDaysLast30(resetAt time.Time) (int64, error) {
|
||||
|
||||
// OrdersCountLast30 renvoie le nombre de commandes sur les 30 derniers jours.
|
||||
func (d *Database) OrdersCountLast30(resetAt time.Time) (int64, error) {
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt)
|
||||
where, args := statusFilterClause("status != 'cancelled'", resetAt, "created_at")
|
||||
var count int64
|
||||
query := `
|
||||
SELECT COUNT(*) FROM commandes
|
||||
|
||||
Reference in New Issue
Block a user