diff --git a/backend/gestion/db/db_stat.go b/backend/gestion/db/db_stat.go new file mode 100644 index 00000000..af302e57 --- /dev/null +++ b/backend/gestion/db/db_stat.go @@ -0,0 +1,322 @@ +package db + +import ( + "gestion/models" + "time" +) + +// ── Reset des sections de stats ───────────────────────────────────────────── + +// ResetAdminStat enregistre (ou met à jour) la date de reset pour une section. +func (d *Database) ResetAdminStat(section string) error { + now := time.Now().UTC().Format(time.RFC3339) + upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` + return d.GDB.Exec(upsert, section, now).Error +} + +// ReadResetAt lit la date de reset stockée pour une clé donnée (zero value si absente). +func (d *Database) ReadResetAt(key string) time.Time { + var row struct { + Value string + } + err := d.GDB.Table("app_settings"). + Select("value"). + Where("key = ?", key). + Scan(&row).Error + if err != nil { + return time.Time{} + } + if row.Value != "" { + if t, err := time.Parse(time.RFC3339, row.Value); err == nil { + return t + } + } + return time.Time{} +} + +// ── Construction des clauses WHERE (filtrage par reset) ──────────────────── + +// statusFilterClause construit " [AND created_at >= ?]" et renvoie +// la clause ainsi que les arguments à binder, dans l'ordre. +func statusFilterClause(baseStatus string, resetAt time.Time) (string, []interface{}) { + if !resetAt.IsZero() { + return baseStatus + " AND created_at >= ?", []interface{}{resetAt.Format(time.RFC3339)} + } + return baseStatus, nil +} + +// AdminStatsFilters regroupe les dates de reset pour chaque section, lues une +// seule fois puis transmises aux différentes requêtes. +type AdminStatsFilters struct { + ResetCommandes time.Time + ResetRevenus time.Time + ResetProduits time.Time + ResetHeures time.Time + ResetJours time.Time + ResetDoses time.Time +} + +// LoadAdminStatsFilters lit toutes les dates de reset nécessaires au dashboard. +func (d *Database) LoadAdminStatsFilters() AdminStatsFilters { + return AdminStatsFilters{ + ResetCommandes: d.ReadResetAt("stats_reset_commandes_at"), + ResetRevenus: d.ReadResetAt("stats_reset_revenus_at"), + ResetProduits: d.ReadResetAt("stats_reset_produits_at"), + ResetHeures: d.ReadResetAt("stats_reset_heures_at"), + ResetJours: d.ReadResetAt("stats_reset_jours_at"), + ResetDoses: d.ReadResetAt("stats_reset_doses_at"), + } +} + +// ── 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) + query := ` + SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count + FROM commandes + WHERE ` + where + ` + GROUP BY dow + ORDER BY dow + ` + return d.GDB.Raw(query, args...).Scan(wdRows).Error +} + +// ── Commandes par jour sur 30 jours ────────────────────────────────────────── + +func (d *Database) OrdersByDayLast30(dayRows *[]models.DayRow, resetAt time.Time) error { + where, args := statusFilterClause("status != 'cancelled'", resetAt) + query := ` + SELECT DATE(created_at) AS day, COUNT(*) AS count + FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' + AND ` + where + ` + GROUP BY DATE(created_at) + ORDER BY day + ` + return d.GDB.Raw(query, args...).Scan(dayRows).Error +} + +// ── 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) + query := ` + SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' + AND ` + where + ` + GROUP BY DATE(created_at) + ORDER BY day + ` + return d.GDB.Raw(query, args...).Scan(dayRevRows).Error +} + +// ── Commandes par jour sur un mois calendaire complet ──────────────────────── + +// DailyMonthStatRow représente l'agrégat d'un jour donné (commandes, revenu, +// quantité vendue) au sein d'un mois calendaire. +type DailyMonthStatRow struct { + Day time.Time + Count int + Revenue float64 + Quantity float64 +} + +// StatsByDayForMonth renvoie, pour chaque jour ayant au moins une commande dans +// le mois calendaire de `monthStart` (1er jour du mois, 00:00), le nombre de +// commandes (non annulées), le revenu (commandes approuvées) et la quantité +// totale d'articles vendus. resetAt filtre sur created_at si non nul. +func (d *Database) StatsByDayForMonth(rows *[]DailyMonthStatRow, monthStart time.Time, resetAt 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) + + query := ` + SELECT + d.day, + COALESCE(co.count, 0) AS count, + COALESCE(rv.revenue, 0) AS revenue, + COALESCE(qt.quantity, 0) AS quantity + FROM ( + SELECT DATE(created_at) AS day, COUNT(*) AS count + FROM commandes + WHERE created_at >= ? AND created_at < ? + AND ` + where + ` + GROUP BY DATE(created_at) + ) d + LEFT JOIN ( + SELECT DATE(created_at) AS day, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE created_at >= ? AND created_at < ? + AND status = 'approved' + GROUP BY DATE(created_at) + ) rv ON rv.day = d.day + LEFT JOIN ( + SELECT DATE(c.created_at) AS day, SUM(ci.quantite) AS quantity + 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' + 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 "co", + // puis (start, end) pour "rv", puis (start, end) pour "qt". + args := []interface{}{start, end} + args = append(args, whereArgs...) + args = append(args, start, end) + args = append(args, start, end) + + return d.GDB.Raw(query, args...).Scan(rows).Error +} + +// ── Commandes & revenus par heure (non annulées) ───────────────────────────── + +// ── Commandes & revenus par heure (non annulées) ───────────────────────────── + +func (d *Database) OrdersAndRevenueByHour(hourRows *[]models.HourRow, resetAt time.Time) error { + where, args := statusFilterClause("status != 'cancelled'", resetAt) + query := ` + SELECT + EXTRACT(HOUR FROM created_at)::int AS hour, + COUNT(*) AS count, + COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue + FROM commandes + WHERE ` + where + ` + GROUP BY hour + ORDER BY hour + ` + return d.GDB.Raw(query, args...).Scan(hourRows).Error +} + +// ── Top produits (quantité vendue) ─────────────────────────────────────────── + +func (d *Database) TopProducts(prodRows *[]models.ProductRow, resetAt time.Time, limit int) error { + where, args := statusFilterClause("c.status != 'cancelled'", resetAt) + args = append(args, limit) + query := ` + SELECT + ci.product_id, + ci.produit AS name, + SUM(ci.quantite) AS total_quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(ci.prix) AS revenue, + COALESCE(p.category, '') AS category, + COALESCE(cat.color, '#7c3aed') AS category_color + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + LEFT JOIN products p ON p.id = ci.product_id + LEFT JOIN categories cat ON cat.name = p.category + WHERE ` + where + ` + GROUP BY ci.product_id, ci.produit, p.category, cat.color + ORDER BY total_quantity DESC + LIMIT ? + ` + return d.GDB.Raw(query, args...).Scan(prodRows).Error +} + +// ── Répartition des doses/quantités par produit ────────────────────────────── + +func (d *Database) QuantityBreakdown(qtyRows *[]models.QuantityBreakdownRow, resetAt time.Time) error { + where, args := statusFilterClause("c.status != 'cancelled'", resetAt) + query := ` + SELECT + ci.product_id, + ci.produit AS product_name, + ci.quantite AS quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(ci.quantite) AS total_sold, + SUM(ci.prix) AS revenue, + COALESCE(cat.color, '#7c3aed') AS category_color + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + LEFT JOIN products p ON p.id = ci.product_id + LEFT JOIN categories cat ON cat.name = p.category + WHERE ` + where + ` + GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color + ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC + ` + return d.GDB.Raw(query, args...).Scan(qtyRows).Error +} + +// ── Détail du jour (catégorie → produits) ──────────────────────────────────── + +func (d *Database) DailyProductDetail(dailyRows *[]models.DailyProductRow) error { + query := ` + SELECT + ci.product_id, + ci.produit AS product_name, + COALESCE(p.category, 'Sans catégorie') AS category, + COALESCE(cat.color, '#7c3aed') AS category_color, + SUM(ci.quantite) AS total_quantity, + COUNT(DISTINCT ci.command_id) AS order_count, + SUM(ci.prix) AS revenue + FROM command_items ci + JOIN commandes c ON c.id = ci.command_id + LEFT JOIN products p ON p.id = ci.product_id + LEFT JOIN categories cat ON cat.name = p.category + WHERE DATE(c.created_at) = CURRENT_DATE + AND c.status != 'cancelled' + GROUP BY ci.product_id, ci.produit, p.category, cat.color + ORDER BY p.category, SUM(ci.quantite) DESC + ` + return d.GDB.Raw(query).Scan(dailyRows).Error +} + +// DailyOrdersCount renvoie le nombre de commandes (non annulées) du jour. +func (d *Database) DailyOrdersCount() (int64, error) { + var count int64 + err := d.GDB.Raw(` + SELECT COUNT(DISTINCT id) FROM commandes + WHERE DATE(created_at) = CURRENT_DATE AND status != 'cancelled' + `).Scan(&count).Error + return count, err +} + +// ── Résumé global ──────────────────────────────────────────────────────────── + +// 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) + var total int64 + err := d.GDB.Raw(`SELECT COUNT(*) FROM commandes WHERE `+where, args...).Scan(&total).Error + return total, err +} + +// 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) + 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 + return total, err +} + +// 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) + var activeDays int64 + query := ` + SELECT COUNT(DISTINCT DATE(created_at)) + FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where + err := d.GDB.Raw(query, args...).Scan(&activeDays).Error + return activeDays, err +} + +// 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) + var count int64 + query := ` + SELECT COUNT(*) FROM commandes + WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + where + err := d.GDB.Raw(query, args...).Scan(&count).Error + return count, err +} diff --git a/backend/gestion/handlers/stats.go b/backend/gestion/handlers/stats.go index f866f272..34e3b131 100644 --- a/backend/gestion/handlers/stats.go +++ b/backend/gestion/handlers/stats.go @@ -30,27 +30,16 @@ func ResetAdminStats(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)}) return } + database := c.MustGet("database").(*db.Database) now := time.Now().UTC().Format(time.RFC3339) - upsert := `INSERT INTO app_settings (key, value) VALUES (?, ?) - ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value` - if err := database.GDB.Exec(upsert, key, now).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "Erreur reset statistiques"}) + + if err := database.ResetAdminStat(key); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)}) return } - c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now}) -} -// readResetAt lit une date de reset depuis app_settings (zero value si absente). -func readResetAt(database *db.Database, key string) time.Time { - var row struct{ Value string } - database.GDB.Table("app_settings").Select("value").Where("key = ?", key).Scan(&row) - if row.Value != "" { - if t, err := time.Parse(time.RFC3339, row.Value); err == nil { - return t - } - } - return time.Time{} + c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now}) } func dateFilter(t time.Time) string { @@ -60,50 +49,83 @@ func dateFilter(t time.Time) string { return t.Format(time.RFC3339) } +// GetAdminStatsByMonth renvoie, pour chaque jour du mois demandé (paramètre +// de query "month" au format YYYY-MM, mois courant par défaut), le nombre de +// commandes, le revenu et la quantité vendue. Les jours sans commande sont +// inclus avec des valeurs à zéro. +func GetAdminStatsByMonth(c *gin.Context) { + database := c.MustGet("database").(*db.Database) + + monthParam := c.Query("month") + monthStart := time.Now() + if monthParam != "" { + parsed, err := time.Parse("2006-01", monthParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("paramètre month invalide (attendu YYYY-MM) : %s", monthParam)}) + return + } + monthStart = parsed + } + monthStart = time.Date(monthStart.Year(), monthStart.Month(), 1, 0, 0, 0, 0, monthStart.Location()) + + resetCmd := database.ReadResetAt("stats_reset_commandes_at") + + var rows []db.DailyMonthStatRow + if err := database.StatsByDayForMonth(&rows, monthStart, resetCmd); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Erreur lors de la récupération des statistiques mensuelles: %s", err)}) + return + } + + rowByDay := make(map[string]db.DailyMonthStatRow, len(rows)) + for _, r := range rows { + rowByDay[r.Day.Format("2006-01-02")] = r + } + + daysInMonth := monthStart.AddDate(0, 1, -1).Day() + byDay := make([]gin.H, daysInMonth) + var totalOrders int + var totalRevenue float64 + var totalQuantity float64 + + for i := range daysInMonth { + day := monthStart.AddDate(0, 0, i) + key := day.Format("2006-01-02") + r, ok := rowByDay[key] + if !ok { + r = db.DailyMonthStatRow{Day: day} + } + byDay[i] = gin.H{ + "day": key, + "label": day.Format("02/01"), + "count": r.Count, + "revenue": r.Revenue, + "quantity": r.Quantity, + } + totalOrders += r.Count + totalRevenue += r.Revenue + totalQuantity += r.Quantity + } + + c.JSON(http.StatusOK, gin.H{ + "month": monthStart.Format("2006-01"), + "summary": gin.H{ + "total_orders": totalOrders, + "total_revenue": totalRevenue, + "total_quantity": totalQuantity, + }, + "by_day": byDay, + }) +} + // GetAdminStats returns aggregated order & product statistics for the admin dashboard. func GetAdminStats(c *gin.Context) { database := c.MustGet("database").(*db.Database) - gdb := database.GDB - resetCmd := readResetAt(database, "stats_reset_commandes_at") - resetRev := readResetAt(database, "stats_reset_revenus_at") - resetProd := readResetAt(database, "stats_reset_produits_at") - resetHeure := readResetAt(database, "stats_reset_heures_at") - resetJour := readResetAt(database, "stats_reset_jours_at") - resetDoses := readResetAt(database, "stats_reset_doses_at") - - filterClause := func(base string, t time.Time) string { - if !t.IsZero() { - return base + " AND created_at >= '" + t.Format(time.RFC3339) + "'" - } - return base - } - - whereCmd := func() string { return filterClause("status != 'cancelled'", resetCmd) } - whereRev := func() string { return filterClause("status = 'approved'", resetRev) } - whereProd := func() string { return filterClause("status != 'cancelled'", resetProd) } - whereHeure := func() string { return filterClause("status != 'cancelled'", resetHeure) } - whereJour := func() string { return filterClause("status != 'cancelled'", resetJour) } - whereDoses := func() string { return filterClause("status != 'cancelled'", resetDoses) } - - whereBase := func(extra string) string { - base := whereCmd() - if extra != "" { - return base + " AND " + extra - } - return base - } - whereApproved := whereRev + filters := database.LoadAdminStatsFilters() // ── Commandes par jour de la semaine (non annulées) ──────────────────────── var wdRows []models.WeekdayRow - gdb.Raw(` - SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count - FROM commandes - WHERE ` + whereJour() + ` - GROUP BY dow - ORDER BY dow - `).Scan(&wdRows) + database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours) byWeekday := make([]gin.H, 7) wdMap := make(map[int]int, len(wdRows)) @@ -111,7 +133,7 @@ func GetAdminStats(c *gin.Context) { wdMap[r.DOW] = r.Count } peakCount, peakWeekday := 0, "" - for i := 0; i < 7; i++ { + for i := range 7 { cnt := wdMap[i] byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt} if cnt > peakCount { @@ -122,14 +144,7 @@ func GetAdminStats(c *gin.Context) { // ── Commandes par jour sur 30 jours ─────────────────────────────────────── var dayRows []models.DayRow - gdb.Raw(` - SELECT DATE(created_at) AS day, COUNT(*) AS count - FROM commandes - WHERE created_at >= NOW() - INTERVAL '30 days' - AND ` + whereBase("") + ` - GROUP BY DATE(created_at) - ORDER BY day - `).Scan(&dayRows) + database.OrdersByDayLast30(&dayRows, filters.ResetCommandes) byDay := make([]gin.H, len(dayRows)) for i, r := range dayRows { @@ -142,14 +157,7 @@ func GetAdminStats(c *gin.Context) { // ── Revenus par jour sur 30 jours (commandes approuvées) ───────────────── var dayRevRows []models.DayRevenueRow - gdb.Raw(` - SELECT DATE(created_at) AS day, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE created_at >= NOW() - INTERVAL '30 days' - AND ` + whereApproved() + ` - GROUP BY DATE(created_at) - ORDER BY day - `).Scan(&dayRevRows) + database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus) byDayRevenue := make([]gin.H, len(dayRevRows)) for i, r := range dayRevRows { @@ -162,23 +170,14 @@ func GetAdminStats(c *gin.Context) { // ── Commandes & revenus par heure (non annulées) ───────────────────────── var hourRows []models.HourRow - gdb.Raw(` - SELECT - EXTRACT(HOUR FROM created_at)::int AS hour, - COUNT(*) AS count, - COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue - FROM commandes - WHERE ` + whereHeure() + ` - GROUP BY hour - ORDER BY hour - `).Scan(&hourRows) + database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures) hourMap := make(map[int]models.HourRow, len(hourRows)) for _, r := range hourRows { hourMap[r.Hour] = r } byHour := make([]gin.H, 24) - for h := 0; h < 24; h++ { + for h := range 24 { r := hourMap[h] byHour[h] = gin.H{ "hour": h, @@ -190,24 +189,7 @@ func GetAdminStats(c *gin.Context) { // ── Top produits (quantité vendue, commandes terminées) ─────────────────── var prodRows []models.ProductRow - gdb.Raw(` - SELECT - ci.product_id, - ci.produit AS name, - SUM(ci.quantite) AS total_quantity, - COUNT(DISTINCT ci.command_id) AS order_count, - SUM(ci.prix) AS revenue, - COALESCE(p.category, '') AS category, - COALESCE(cat.color, '#7c3aed') AS category_color - FROM command_items ci - JOIN commandes c ON c.id = ci.command_id - LEFT JOIN products p ON p.id = ci.product_id - LEFT JOIN categories cat ON cat.name = p.category - WHERE c.` + whereProd() + ` - GROUP BY ci.product_id, ci.produit, p.category, cat.color - ORDER BY total_quantity DESC - LIMIT 15 - `).Scan(&prodRows) + database.TopProducts(&prodRows, filters.ResetProduits, 15) topProducts := make([]gin.H, len(prodRows)) topProductName := "" @@ -228,23 +210,7 @@ func GetAdminStats(c *gin.Context) { // ── Répartition des doses/quantités par produit ─────────────────────────── var qtyRows []models.QuantityBreakdownRow - gdb.Raw(` - SELECT - ci.product_id, - ci.produit AS product_name, - ci.quantite AS quantity, - COUNT(DISTINCT ci.command_id) AS order_count, - SUM(ci.quantite) AS total_sold, - SUM(ci.prix) AS revenue, - COALESCE(cat.color, '#7c3aed') AS category_color - FROM command_items ci - JOIN commandes c ON c.id = ci.command_id - LEFT JOIN products p ON p.id = ci.product_id - LEFT JOIN categories cat ON cat.name = p.category - WHERE c.` + whereDoses() + ` - GROUP BY ci.product_id, ci.produit, ci.quantite, cat.color - ORDER BY ci.product_id, COUNT(DISTINCT ci.command_id) DESC - `).Scan(&qtyRows) + database.QuantityBreakdown(&qtyRows, filters.ResetDoses) type productGroup struct { ProductID int @@ -274,6 +240,7 @@ func GetAdminStats(c *gin.Context) { "revenue": r.Revenue, }) } + // Trier par total de commandes décroissant, garder 15 max for i := 0; i < len(groups)-1; i++ { for j := i + 1; j < len(groups); j++ { @@ -285,6 +252,7 @@ func GetAdminStats(c *gin.Context) { if len(groups) > 15 { groups = groups[:15] } + byQuantity := make([]gin.H, len(groups)) for i, g := range groups { byQuantity[i] = gin.H{ @@ -298,24 +266,7 @@ func GetAdminStats(c *gin.Context) { // ── Détail du jour (catégorie → produits) ──────────────────────────────── var dailyRows []models.DailyProductRow - gdb.Raw(` - SELECT - ci.product_id, - ci.produit AS product_name, - COALESCE(p.category, 'Sans catégorie') AS category, - COALESCE(cat.color, '#7c3aed') AS category_color, - SUM(ci.quantite) AS total_quantity, - COUNT(DISTINCT ci.command_id) AS order_count, - SUM(ci.prix) AS revenue - FROM command_items ci - JOIN commandes c ON c.id = ci.command_id - LEFT JOIN products p ON p.id = ci.product_id - LEFT JOIN categories cat ON cat.name = p.category - WHERE DATE(c.created_at) = CURRENT_DATE - AND c.status != 'cancelled' - GROUP BY ci.product_id, ci.produit, p.category, cat.color - ORDER BY p.category, SUM(ci.quantite) DESC - `).Scan(&dailyRows) + database.DailyProductDetail(&dailyRows) type dailyCatGroup struct { Category string @@ -326,15 +277,9 @@ func GetAdminStats(c *gin.Context) { } var dailyCats []dailyCatGroup dailyCatIdx := map[string]int{} - var dailyTotalOrders int64 dailyTotalRevenue := 0.0 dailyTotalQty := 0.0 - gdb.Raw(` - SELECT COUNT(DISTINCT id) FROM commandes - WHERE DATE(created_at) = CURRENT_DATE AND status != 'cancelled' - `).Scan(&dailyTotalOrders) - for _, r := range dailyRows { dailyTotalRevenue += r.Revenue dailyTotalQty += r.TotalQuantity @@ -357,6 +302,7 @@ func GetAdminStats(c *gin.Context) { "revenue": r.Revenue, }) } + dailyCatsJSON := make([]gin.H, len(dailyCats)) for i, g := range dailyCats { dailyCatsJSON[i] = gin.H{ @@ -368,24 +314,17 @@ func GetAdminStats(c *gin.Context) { } } + dailyTotalOrders, _ := database.DailyOrdersCount() + // ── Résumé global ───────────────────────────────────────────────────────── - var totalOrders int64 - var totalRevenue float64 - gdb.Raw(`SELECT COUNT(*) FROM commandes WHERE ` + whereBase("")).Scan(&totalOrders) - gdb.Raw(`SELECT COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) FROM commandes WHERE ` + whereApproved()).Scan(&totalRevenue) + totalOrders, _ := database.TotalOrders(filters.ResetCommandes) + totalRevenue, _ := database.TotalRevenue(filters.ResetRevenus) avgPerDay := 0.0 if totalOrders > 0 { - var activeDays int64 - gdb.Raw(` - SELECT COUNT(DISTINCT DATE(created_at)) - FROM commandes - WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + whereBase("")).Scan(&activeDays) + activeDays, _ := database.ActiveDaysLast30(filters.ResetCommandes) if activeDays > 0 { - var last30Count int64 - gdb.Raw(` - SELECT COUNT(*) FROM commandes - WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + whereBase("")).Scan(&last30Count) + last30Count, _ := database.OrdersCountLast30(filters.ResetCommandes) avgPerDay = float64(last30Count) / float64(activeDays) } } @@ -398,18 +337,18 @@ func GetAdminStats(c *gin.Context) { "top_product": topProductName, "avg_per_day": avgPerDay, }, - "reset_at_commandes": dateFilter(resetCmd), - "reset_at_revenus": dateFilter(resetRev), - "reset_at_produits": dateFilter(resetProd), - "reset_at_heures": dateFilter(resetHeure), - "reset_at_jours": dateFilter(resetJour), - "reset_at_doses": dateFilter(resetDoses), + "reset_at_commandes": dateFilter(filters.ResetCommandes), + "reset_at_revenus": dateFilter(filters.ResetRevenus), + "reset_at_produits": dateFilter(filters.ResetProduits), + "reset_at_heures": dateFilter(filters.ResetHeures), + "reset_at_jours": dateFilter(filters.ResetJours), + "reset_at_doses": dateFilter(filters.ResetDoses), "by_weekday": byWeekday, "by_day_30": byDay, "by_day_revenue": byDayRevenue, "by_hour": byHour, "top_products": topProducts, - "by_quantity": byQuantity, + "by_quantity": byQuantity, "daily_detail": gin.H{ "date": time.Now().Format("02/01/2006"), "total_orders": dailyTotalOrders, diff --git a/backend/gestion/routes/routes.go b/backend/gestion/routes/routes.go index 82b953c4..9f63ab0e 100644 --- a/backend/gestion/routes/routes.go +++ b/backend/gestion/routes/routes.go @@ -209,7 +209,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services // ============================================ adminGroupV2.GET("/stats", handlers.GetAdminStats) adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats) - + adminGroupV2.GET("/stats/monthly", handlers.GetAdminStatsByMonth) adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice) adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice) @@ -275,10 +275,10 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services adminGroupV2.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité adminGroupV2.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client adminGroupV2.POST("/client/:username/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende - adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0 - adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool - adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool - adminGroupV2.POST("/client/:username/rewards/reset", handlers.AdminResetClientRedeemed) // Reset récompenses réclamées + adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0 + adminGroupV2.POST("/client/:username/points/add", handlers.AddClientPointsAdmin) // Ajouter points par pool + adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever points par pool + adminGroupV2.POST("/client/:username/rewards/reset", handlers.AdminResetClientRedeemed) // Reset récompenses réclamées adminGroupV2.GET("/penalties/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)