@@ -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 "<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{}) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -30,27 +30,16 @@ func ResetAdminStats(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("section invalide : %s", section)})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
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.ResetAdminStat(key); err != nil {
|
||||||
if err := database.GDB.Exec(upsert, key, now).Error; err != nil {
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Erreur lors de la suppresion de la section statistique: %s", err)})
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "Erreur reset statistiques"})
|
|
||||||
return
|
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).
|
c.JSON(http.StatusOK, gin.H{"success": true, "section": section, "reset_at": now})
|
||||||
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{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func dateFilter(t time.Time) string {
|
func dateFilter(t time.Time) string {
|
||||||
@@ -60,50 +49,83 @@ func dateFilter(t time.Time) string {
|
|||||||
return t.Format(time.RFC3339)
|
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.
|
// GetAdminStats returns aggregated order & product statistics for the admin dashboard.
|
||||||
func GetAdminStats(c *gin.Context) {
|
func GetAdminStats(c *gin.Context) {
|
||||||
database := c.MustGet("database").(*db.Database)
|
database := c.MustGet("database").(*db.Database)
|
||||||
gdb := database.GDB
|
|
||||||
|
|
||||||
resetCmd := readResetAt(database, "stats_reset_commandes_at")
|
filters := database.LoadAdminStatsFilters()
|
||||||
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
|
|
||||||
|
|
||||||
// ── Commandes par jour de la semaine (non annulées) ────────────────────────
|
// ── Commandes par jour de la semaine (non annulées) ────────────────────────
|
||||||
var wdRows []models.WeekdayRow
|
var wdRows []models.WeekdayRow
|
||||||
gdb.Raw(`
|
database.OrderPerDaysPerWeeks(&wdRows, filters.ResetJours)
|
||||||
SELECT EXTRACT(DOW FROM created_at)::int AS dow, COUNT(*) AS count
|
|
||||||
FROM commandes
|
|
||||||
WHERE ` + whereJour() + `
|
|
||||||
GROUP BY dow
|
|
||||||
ORDER BY dow
|
|
||||||
`).Scan(&wdRows)
|
|
||||||
|
|
||||||
byWeekday := make([]gin.H, 7)
|
byWeekday := make([]gin.H, 7)
|
||||||
wdMap := make(map[int]int, len(wdRows))
|
wdMap := make(map[int]int, len(wdRows))
|
||||||
@@ -111,7 +133,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
wdMap[r.DOW] = r.Count
|
wdMap[r.DOW] = r.Count
|
||||||
}
|
}
|
||||||
peakCount, peakWeekday := 0, ""
|
peakCount, peakWeekday := 0, ""
|
||||||
for i := 0; i < 7; i++ {
|
for i := range 7 {
|
||||||
cnt := wdMap[i]
|
cnt := wdMap[i]
|
||||||
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
byWeekday[i] = gin.H{"weekday": weekdayNames[i], "count": cnt}
|
||||||
if cnt > peakCount {
|
if cnt > peakCount {
|
||||||
@@ -122,14 +144,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
// ── Commandes par jour sur 30 jours ───────────────────────────────────────
|
||||||
var dayRows []models.DayRow
|
var dayRows []models.DayRow
|
||||||
gdb.Raw(`
|
database.OrdersByDayLast30(&dayRows, filters.ResetCommandes)
|
||||||
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)
|
|
||||||
|
|
||||||
byDay := make([]gin.H, len(dayRows))
|
byDay := make([]gin.H, len(dayRows))
|
||||||
for i, r := range dayRows {
|
for i, r := range dayRows {
|
||||||
@@ -142,14 +157,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────
|
// ── Revenus par jour sur 30 jours (commandes approuvées) ─────────────────
|
||||||
var dayRevRows []models.DayRevenueRow
|
var dayRevRows []models.DayRevenueRow
|
||||||
gdb.Raw(`
|
database.RevenueByDayLast30(&dayRevRows, filters.ResetRevenus)
|
||||||
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)
|
|
||||||
|
|
||||||
byDayRevenue := make([]gin.H, len(dayRevRows))
|
byDayRevenue := make([]gin.H, len(dayRevRows))
|
||||||
for i, r := range dayRevRows {
|
for i, r := range dayRevRows {
|
||||||
@@ -162,23 +170,14 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Commandes & revenus par heure (non annulées) ─────────────────────────
|
// ── Commandes & revenus par heure (non annulées) ─────────────────────────
|
||||||
var hourRows []models.HourRow
|
var hourRows []models.HourRow
|
||||||
gdb.Raw(`
|
database.OrdersAndRevenueByHour(&hourRows, filters.ResetHeures)
|
||||||
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)
|
|
||||||
|
|
||||||
hourMap := make(map[int]models.HourRow, len(hourRows))
|
hourMap := make(map[int]models.HourRow, len(hourRows))
|
||||||
for _, r := range hourRows {
|
for _, r := range hourRows {
|
||||||
hourMap[r.Hour] = r
|
hourMap[r.Hour] = r
|
||||||
}
|
}
|
||||||
byHour := make([]gin.H, 24)
|
byHour := make([]gin.H, 24)
|
||||||
for h := 0; h < 24; h++ {
|
for h := range 24 {
|
||||||
r := hourMap[h]
|
r := hourMap[h]
|
||||||
byHour[h] = gin.H{
|
byHour[h] = gin.H{
|
||||||
"hour": h,
|
"hour": h,
|
||||||
@@ -190,24 +189,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
|
// ── Top produits (quantité vendue, commandes terminées) ───────────────────
|
||||||
var prodRows []models.ProductRow
|
var prodRows []models.ProductRow
|
||||||
gdb.Raw(`
|
database.TopProducts(&prodRows, filters.ResetProduits, 15)
|
||||||
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)
|
|
||||||
|
|
||||||
topProducts := make([]gin.H, len(prodRows))
|
topProducts := make([]gin.H, len(prodRows))
|
||||||
topProductName := ""
|
topProductName := ""
|
||||||
@@ -228,23 +210,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Répartition des doses/quantités par produit ───────────────────────────
|
// ── Répartition des doses/quantités par produit ───────────────────────────
|
||||||
var qtyRows []models.QuantityBreakdownRow
|
var qtyRows []models.QuantityBreakdownRow
|
||||||
gdb.Raw(`
|
database.QuantityBreakdown(&qtyRows, filters.ResetDoses)
|
||||||
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)
|
|
||||||
|
|
||||||
type productGroup struct {
|
type productGroup struct {
|
||||||
ProductID int
|
ProductID int
|
||||||
@@ -274,6 +240,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
"revenue": r.Revenue,
|
"revenue": r.Revenue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trier par total de commandes décroissant, garder 15 max
|
// Trier par total de commandes décroissant, garder 15 max
|
||||||
for i := 0; i < len(groups)-1; i++ {
|
for i := 0; i < len(groups)-1; i++ {
|
||||||
for j := i + 1; j < len(groups); j++ {
|
for j := i + 1; j < len(groups); j++ {
|
||||||
@@ -285,6 +252,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
if len(groups) > 15 {
|
if len(groups) > 15 {
|
||||||
groups = groups[:15]
|
groups = groups[:15]
|
||||||
}
|
}
|
||||||
|
|
||||||
byQuantity := make([]gin.H, len(groups))
|
byQuantity := make([]gin.H, len(groups))
|
||||||
for i, g := range groups {
|
for i, g := range groups {
|
||||||
byQuantity[i] = gin.H{
|
byQuantity[i] = gin.H{
|
||||||
@@ -298,24 +266,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Détail du jour (catégorie → produits) ────────────────────────────────
|
// ── Détail du jour (catégorie → produits) ────────────────────────────────
|
||||||
var dailyRows []models.DailyProductRow
|
var dailyRows []models.DailyProductRow
|
||||||
gdb.Raw(`
|
database.DailyProductDetail(&dailyRows)
|
||||||
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)
|
|
||||||
|
|
||||||
type dailyCatGroup struct {
|
type dailyCatGroup struct {
|
||||||
Category string
|
Category string
|
||||||
@@ -326,15 +277,9 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
var dailyCats []dailyCatGroup
|
var dailyCats []dailyCatGroup
|
||||||
dailyCatIdx := map[string]int{}
|
dailyCatIdx := map[string]int{}
|
||||||
var dailyTotalOrders int64
|
|
||||||
dailyTotalRevenue := 0.0
|
dailyTotalRevenue := 0.0
|
||||||
dailyTotalQty := 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 {
|
for _, r := range dailyRows {
|
||||||
dailyTotalRevenue += r.Revenue
|
dailyTotalRevenue += r.Revenue
|
||||||
dailyTotalQty += r.TotalQuantity
|
dailyTotalQty += r.TotalQuantity
|
||||||
@@ -357,6 +302,7 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
"revenue": r.Revenue,
|
"revenue": r.Revenue,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
dailyCatsJSON := make([]gin.H, len(dailyCats))
|
||||||
for i, g := range dailyCats {
|
for i, g := range dailyCats {
|
||||||
dailyCatsJSON[i] = gin.H{
|
dailyCatsJSON[i] = gin.H{
|
||||||
@@ -368,24 +314,17 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dailyTotalOrders, _ := database.DailyOrdersCount()
|
||||||
|
|
||||||
// ── Résumé global ─────────────────────────────────────────────────────────
|
// ── Résumé global ─────────────────────────────────────────────────────────
|
||||||
var totalOrders int64
|
totalOrders, _ := database.TotalOrders(filters.ResetCommandes)
|
||||||
var totalRevenue float64
|
totalRevenue, _ := database.TotalRevenue(filters.ResetRevenus)
|
||||||
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)
|
|
||||||
|
|
||||||
avgPerDay := 0.0
|
avgPerDay := 0.0
|
||||||
if totalOrders > 0 {
|
if totalOrders > 0 {
|
||||||
var activeDays int64
|
activeDays, _ := database.ActiveDaysLast30(filters.ResetCommandes)
|
||||||
gdb.Raw(`
|
|
||||||
SELECT COUNT(DISTINCT DATE(created_at))
|
|
||||||
FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + whereBase("")).Scan(&activeDays)
|
|
||||||
if activeDays > 0 {
|
if activeDays > 0 {
|
||||||
var last30Count int64
|
last30Count, _ := database.OrdersCountLast30(filters.ResetCommandes)
|
||||||
gdb.Raw(`
|
|
||||||
SELECT COUNT(*) FROM commandes
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '30 days' AND ` + whereBase("")).Scan(&last30Count)
|
|
||||||
avgPerDay = float64(last30Count) / float64(activeDays)
|
avgPerDay = float64(last30Count) / float64(activeDays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -398,18 +337,18 @@ func GetAdminStats(c *gin.Context) {
|
|||||||
"top_product": topProductName,
|
"top_product": topProductName,
|
||||||
"avg_per_day": avgPerDay,
|
"avg_per_day": avgPerDay,
|
||||||
},
|
},
|
||||||
"reset_at_commandes": dateFilter(resetCmd),
|
"reset_at_commandes": dateFilter(filters.ResetCommandes),
|
||||||
"reset_at_revenus": dateFilter(resetRev),
|
"reset_at_revenus": dateFilter(filters.ResetRevenus),
|
||||||
"reset_at_produits": dateFilter(resetProd),
|
"reset_at_produits": dateFilter(filters.ResetProduits),
|
||||||
"reset_at_heures": dateFilter(resetHeure),
|
"reset_at_heures": dateFilter(filters.ResetHeures),
|
||||||
"reset_at_jours": dateFilter(resetJour),
|
"reset_at_jours": dateFilter(filters.ResetJours),
|
||||||
"reset_at_doses": dateFilter(resetDoses),
|
"reset_at_doses": dateFilter(filters.ResetDoses),
|
||||||
"by_weekday": byWeekday,
|
"by_weekday": byWeekday,
|
||||||
"by_day_30": byDay,
|
"by_day_30": byDay,
|
||||||
"by_day_revenue": byDayRevenue,
|
"by_day_revenue": byDayRevenue,
|
||||||
"by_hour": byHour,
|
"by_hour": byHour,
|
||||||
"top_products": topProducts,
|
"top_products": topProducts,
|
||||||
"by_quantity": byQuantity,
|
"by_quantity": byQuantity,
|
||||||
"daily_detail": gin.H{
|
"daily_detail": gin.H{
|
||||||
"date": time.Now().Format("02/01/2006"),
|
"date": time.Now().Format("02/01/2006"),
|
||||||
"total_orders": dailyTotalOrders,
|
"total_orders": dailyTotalOrders,
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ func SetupRoutes(router *gin.Engine, database *db.Database, geoService *services
|
|||||||
// ============================================
|
// ============================================
|
||||||
adminGroupV2.GET("/stats", handlers.GetAdminStats)
|
adminGroupV2.GET("/stats", handlers.GetAdminStats)
|
||||||
adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats)
|
adminGroupV2.POST("/stats/reset/:section", handlers.ResetAdminStats)
|
||||||
|
adminGroupV2.GET("/stats/monthly", handlers.GetAdminStatsByMonth)
|
||||||
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
|
adminGroupV2.POST("/active/product/price/:id", handlers.ActivePrice)
|
||||||
adminGroupV2.POST("/desactive/product/price/:id", handlers.DesActivePrice)
|
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.POST("/penalty", handlers.ApplyClientPenalty) // Appliquer pénalité
|
||||||
adminGroupV2.GET("/client/:username/penalties", handlers.GetClientPenaltiesAdmin) // Voir pénalités client
|
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/penalties/reset", handlers.ResetClientPenaltiesAdmin) // Reset amende
|
||||||
adminGroupV2.POST("/client/:username/point/reset", handlers.ResetClientPointAdmin) // Reset points → 0
|
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/add", handlers.AddClientPointsAdmin) // Ajouter points par pool
|
||||||
adminGroupV2.POST("/client/:username/points/subtract", handlers.SubtractClientPointsAdmin) // Enlever 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/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/all", handlers.GetAllClientsWithPenalties) // Liste clients avec pénalités
|
||||||
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
|
adminGroupV2.GET("/penalties/stats", handlers.GetPenaltiesStats)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user