Files
projet_gestion_commande/backend/gestion/db/db_stat.go
T
Xor290 f84dc153ec
Backend - Build & Lint / build (push) Successful in 32m46s
Frontend Admin - EAS Build / build (push) Successful in 1h34m15s
chore: build
2026-09-13 13:19:08 +02:00

496 lines
19 KiB
Go

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 <dateColumn> >= ?]" et
// renvoie la clause ainsi que les arguments à binder, dans l'ordre.
// dateColumn doit être qualifié par l'alias de table (ex: "c.created_at") dès
// que la requête appelante fait une jointure où plusieurs tables possèdent une
// colonne created_at, sous peine d'erreur Postgres "ambiguous column".
func statusFilterClause(baseStatus string, resetAt time.Time, dateColumn string) (string, []interface{}) {
if !resetAt.IsZero() {
return baseStatus + " AND " + 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
}