383 lines
14 KiB
Go
383 lines
14 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 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 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)
|
|
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 ────────────────────────
|
|
|
|
type DailyMonthStatRow struct {
|
|
Day time.Time
|
|
Count int
|
|
Revenue float64
|
|
Quantity float64
|
|
}
|
|
|
|
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(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 ` + 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 "d",
|
|
// 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
|
|
}
|
|
|
|
// 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)
|
|
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)
|
|
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 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)
|
|
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 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 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 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)
|
|
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
|
|
}
|