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 }