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 >= ?]" 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 " + dateColumn + " >= ?", []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 en une seule requête. func (d *Database) LoadAdminStatsFilters() AdminStatsFilters { keys := []string{ "stats_reset_commandes_at", "stats_reset_revenus_at", "stats_reset_produits_at", "stats_reset_heures_at", "stats_reset_jours_at", "stats_reset_doses_at", } var rows []struct { Key string `gorm:"column:key"` Value string `gorm:"column:value"` } d.GDB.Table("app_settings").Select("key, value").Where("key IN ?", keys).Scan(&rows) m := make(map[string]time.Time, len(keys)) for _, r := range rows { if t, err := time.Parse(time.RFC3339, r.Value); err == nil { m[r.Key] = t } } return AdminStatsFilters{ ResetCommandes: m["stats_reset_commandes_at"], ResetRevenus: m["stats_reset_revenus_at"], ResetProduits: m["stats_reset_produits_at"], ResetHeures: m["stats_reset_heures_at"], ResetJours: m["stats_reset_jours_at"], ResetDoses: m["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, "created_at") 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, "created_at") 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, "created_at") 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 ──────────────────────── type DailyMonthStatRow struct { Day time.Time Count int Revenue float64 Quantity float64 } // 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) 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 d.day, COALESCE(d.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 ` + whereCount + ` 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 ` + whereRevenue + ` 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 ` + 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, [resetCommandes]) pour "d", // puis (start, end, [resetRevenus]) pour "rv", puis (start, end, [resetCommandes]) pour "qt". args := []interface{}{start, end} 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 } // OrdersAndRevenueByHour renvoie, par heure, le nombre de commandes non annulées // (volume d'activité) et le revenu confirmé (commandes approuvées uniquement — // 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, "created_at") query := ` SELECT EXTRACT(HOUR FROM created_at)::int AS hour, COUNT(*) AS count, COALESCE(SUM(CASE WHEN status = 'approved' THEN total_prix - COALESCE(referral_used, 0) ELSE 0 END), 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) ─────────────────────────────────────────── // TopProducts renvoie les produits les plus commandés. La quantité/le nombre de // 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, "c.created_at") 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(CASE WHEN c.status = 'approved' THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0) ELSE 0 END) 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 ────────────────────────────── // 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, "c.created_at") 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(CASE WHEN c.status = 'approved' THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0) ELSE 0 END) 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) ──────────────────────────────────── // DailyProductDetail : 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) 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(CASE WHEN c.status = 'approved' THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0) ELSE 0 END) 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 } func (d *Database) DailyProductDetailForDate(dailyRows *[]models.DailyProductRow, date time.Time) error { start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) end := start.AddDate(0, 0, 1) 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(CASE WHEN c.status = 'approved' THEN ci.prix * (c.total_prix - COALESCE(c.referral_used, 0)) / NULLIF(c.total_prix, 0) ELSE 0 END) 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 c.created_at >= ? AND c.created_at < ? 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, start, end).Scan(dailyRows).Error } // DailyOrdersCountForDate renvoie le nombre de commandes (non annulées) pour // une date précise. func (d *Database) DailyOrdersCountForDate(date time.Time) (int64, error) { start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location()) end := start.AddDate(0, 0, 1) var count int64 err := d.GDB.Raw(` SELECT COUNT(DISTINCT id) FROM commandes WHERE created_at >= ? AND created_at < ? AND status != 'cancelled' `, start, end).Scan(&count).Error return count, err } // 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, "created_at") 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, "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 return total, err } // TotalPromoDiscount renvoie le montant total (€) des réductions de prix // accordées par des promotions sur les commandes approuvées, filtré par le // reset "revenus" (même périmètre que TotalRevenue, dont c'est un // sous-indicateur). Basé sur command_items.promo_discount, capturé au moment // de AddToBasket — reflète donc les promos réellement appliquées à l'époque, // pas la config de promotions courante. func (d *Database) TotalPromoDiscount(resetAt time.Time) (float64, error) { where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at") var total float64 query := ` SELECT COALESCE(SUM(ci.promo_discount), 0) FROM command_items ci JOIN commandes c ON c.id = ci.command_id WHERE ` + where err := d.GDB.Raw(query, args...).Scan(&total).Error return total, err } // PromoOrdersCount renvoie le nombre de commandes distinctes (approuvées) // ayant bénéficié d'au moins une réduction de prix promo, filtré par le // reset "revenus". func (d *Database) PromoOrdersCount(resetAt time.Time) (int64, error) { where, args := statusFilterClause("c.status = 'approved'", resetAt, "c.created_at") var count int64 query := ` SELECT COUNT(DISTINCT ci.command_id) FROM command_items ci JOIN commandes c ON c.id = ci.command_id WHERE ci.promo_discount > 0 AND ` + where err := d.GDB.Raw(query, args...).Scan(&count).Error return count, 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, "created_at") 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, "created_at") 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 } func (d *Database) GetMyDeliveryStatsPerDay(statsRows *[]models.DayRowWithResult, username string) error { query := ` SELECT DATE(updated_at) AS day, COUNT(*) AS count, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue FROM commandes WHERE livreur_assign = ? AND status IN ('livre', 'approved') AND updated_at >= NOW() - INTERVAL '30 days' GROUP BY DATE(updated_at) ORDER BY day ` return d.GDB.Raw(query, username).Scan(statsRows).Error } func (d *Database) GetMyDeliveryStatsPerWeek(statsRow *[]models.WeekRow, username string) error { query := ` SELECT EXTRACT(WEEK FROM updated_at)::int AS week_num, EXTRACT(YEAR FROM updated_at)::int AS year, COUNT(*) AS count, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue FROM commandes WHERE livreur_assign = ? AND status IN ('livre', 'approved') AND updated_at >= NOW() - INTERVAL '12 weeks' GROUP BY week_num, year ORDER BY year, week_num ` return d.GDB.Raw(query, username).Scan(statsRow).Error } func (d *Database) GetMyDeliveryStatsPerMonth(statsRow *[]models.MonthRow, username string) error { query := ` SELECT EXTRACT(MONTH FROM updated_at)::int AS month_num, EXTRACT(YEAR FROM updated_at)::int AS year, COUNT(*) AS count, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue FROM commandes WHERE livreur_assign = ? AND status IN ('livre', 'approved') AND updated_at >= NOW() - INTERVAL '12 months' GROUP BY month_num, year ORDER BY year, month_num ` return d.GDB.Raw(query, username).Scan(statsRow).Error } func (d *Database) GetMyDeliveryStatsToday(statsRow *models.TodayRow, username string) error { query := ` SELECT COUNT(*) AS count, COALESCE(SUM(total_prix - COALESCE(referral_used, 0)), 0) AS revenue FROM commandes WHERE livreur_assign = ? AND status IN ('livre', 'approved') AND DATE(updated_at) = CURRENT_DATE ` return d.GDB.Raw(query, username).Scan(statsRow).Error }