package admin import ( "context" "net/http" "strconv" "strings" "time" "hakemsho/internal/httpx" ) // iranTZ منطقه‌ی زمانیِ ایران (UTC+3:30؛ بدون DST از ۲۰۲۲). همین الگو در admin.go // و api.go به‌صورت inline استفاده شده؛ اینجا در سطح پکیج برای چند نقطهِ مشترک. var iranTZ = time.FixedZone("IRST", 12600) const tsLayout = "2006-01-02 15:04:05" // apiPurchases فهرستِ تراکنش‌های خرید را به‌همراه آمارهای کلی برمی‌گرداند. // پارامترها: q (موبایل/نام/شناسه کاربر)، store، kind، status، user_id، // page (پیش‌فرض ۱) و page_size (پیش‌فرض ۵۰، سقف ۲۰۰). func (h *Handler) apiPurchases(w http.ResponseWriter, r *http.Request) { ctx := r.Context() q := strings.TrimSpace(r.URL.Query().Get("q")) store := strings.TrimSpace(r.URL.Query().Get("store")) kind := strings.TrimSpace(r.URL.Query().Get("kind")) status := strings.TrimSpace(r.URL.Query().Get("status")) userID, _ := strconv.ParseInt(r.URL.Query().Get("user_id"), 10, 64) page := atoiDefault(r.URL.Query().Get("page"), 1) if page < 1 { page = 1 } pageSize := atoiDefault(r.URL.Query().Get("page_size"), 50) if pageSize < 1 { pageSize = 50 } if pageSize > 200 { pageSize = 200 } var where []string var args []any if q != "" { like := "%" + q + "%" where = append(where, `(u.mobile LIKE ? OR u.first_name LIKE ? OR CAST(p.user_id AS TEXT) = ?)`) args = append(args, like, like, q) } if store != "" { where = append(where, `p.store = ?`) args = append(args, store) } if kind != "" { where = append(where, `p.kind = ?`) args = append(args, kind) } if status != "" { where = append(where, `p.status = ?`) args = append(args, status) } if userID > 0 { where = append(where, `p.user_id = ?`) args = append(args, userID) } whereSQL := "" if len(where) > 0 { whereSQL = " WHERE " + strings.Join(where, " AND ") } var total int64 _ = h.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM purchases p LEFT JOIN users u ON u.id = p.user_id`+whereSQL, args...).Scan(&total) list := h.purchaseRows(ctx, whereSQL, args, pageSize, (page-1)*pageSize) pages := int64(1) if pageSize > 0 { pages = (total + int64(pageSize) - 1) / int64(pageSize) } if pages < 1 { pages = 1 } httpx.JSON(w, http.StatusOK, map[string]any{ "purchases": list, "stats": h.purchasesStats(ctx), "total": total, "page": page, "page_size": pageSize, "pages": pages, }) } // purchaseRows فهرستِ خریدها را با مشخصاتِ کاربر برمی‌گرداند و روی هر ردیف // «عنوانِ محصول» و «زمانِ تهران» را اضافه می‌کند. func (h *Handler) purchaseRows(ctx context.Context, whereSQL string, args []any, limit, offset int) []map[string]any { q := `SELECT p.id, p.user_id, COALESCE(u.mobile,''), COALESCE(u.first_name,''), p.store, p.kind, p.product_id, p.coins, p.tickets, p.vip_days, p.price_toman, p.status, p.created_at FROM purchases p LEFT JOIN users u ON u.id = p.user_id` + whereSQL + ` ORDER BY p.id DESC LIMIT ? OFFSET ?` qArgs := append(append([]any{}, args...), limit, offset) rows := h.rowsArgs(ctx, q, qArgs, "id", "user_id", "mobile", "name", "store", "kind", "product_id", "coins", "tickets", "vip_days", "price_toman", "status", "created_at") titles := h.productTitles() for _, row := range rows { kind, _ := row["kind"].(string) pid, _ := row["product_id"].(string) row["product_title"] = titles[kind+":"+pid] if s, _ := row["product_title"].(string); s == "" { row["product_title"] = pid } if raw, _ := row["created_at"].(string); raw != "" { row["created_local"] = toTehran(raw) } else { row["created_local"] = "" } } return rows } // productTitles نگاشتِ «نوع:شناسه» → عنوانِ محصول را از کاتالوگِ کش‌شده می‌سازد. func (h *Handler) productTitles() map[string]string { c := h.eco.Catalog() m := make(map[string]string) for _, p := range c.CoinPackages { m["coin:"+p.ID] = p.Title } for _, p := range c.TicketPackages { m["ticket:"+p.ID] = p.Title } for _, p := range c.Boosters { m["booster:"+p.ID] = p.Title } for _, p := range c.VIPPackages { m["vip:"+p.ID] = p.Title } return m } // purchasesStats آمارِ کلیِ خریدهای تأییدشده (برای کارت‌های بالای پنلِ تراکنش‌ها). // بازه‌های «امروز/این ماه/۱۴ روز» با نیمه‌شبِ تهران در Go محاسبه و با رشته‌ی UTC // مقایسه می‌شوند تا به تفاوتِ منطقه‌ی زمانیِ SQLite وابسته نباشیم. func (h *Handler) purchasesStats(ctx context.Context) map[string]any { now := time.Now() tehranNow := now.In(iranTZ) dayStart := tehranNow.UTC().Truncate(24 * time.Hour).Format(tsLayout) monthStart := time.Date(tehranNow.Year(), tehranNow.Month(), 1, 0, 0, 0, 0, iranTZ).UTC().Format(tsLayout) weekAgo := now.UTC().AddDate(0, 0, -13).Format(tsLayout) agg := func(since string) (int64, int64) { var n, sum int64 q := `SELECT COUNT(*), COALESCE(SUM(price_toman),0) FROM purchases WHERE status='verified'` var args []any if since != "" { q += ` AND created_at >= ?` args = append(args, since) } _ = h.db.QueryRowContext(ctx, q, args...).Scan(&n, &sum) return n, sum } stat := func(since string) map[string]any { n, sum := agg(since) return map[string]any{"count": n, "revenue": sum} } totalN, totalSum := agg("") var avg int64 if totalN > 0 { avg = totalSum / totalN } var buyers int64 _ = h.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM purchases WHERE status='verified'`).Scan(&buyers) // ۱۴ روزِ گذشته (تهران): یک کوئریِ محدود و جمع‌بندی در Go. days := make(map[string]int64, 14) counts := make(map[string]int64, 14) rs, err := h.db.QueryContext(ctx, `SELECT created_at, price_toman FROM purchases WHERE status='verified' AND created_at >= ?`, weekAgo) if err == nil { defer rs.Close() for rs.Next() { var raw string var price int64 if rs.Scan(&raw, &price) != nil { continue } d := "?" if t, err := time.Parse(tsLayout, raw); err == nil { d = t.In(iranTZ).Format("2006-01-02") } days[d] += price counts[d]++ } } var last14 []map[string]any for i := 13; i >= 0; i-- { d := tehranNow.AddDate(0, 0, -i).Format("2006-01-02") last14 = append(last14, map[string]any{ "date": d, "revenue": days[d], "count": counts[d], }) } return map[string]any{ "total_revenue": totalSum, "verified_count": totalN, "unique_buyers": buyers, "avg_revenue": avg, "today": stat(dayStart), "month": stat(monthStart), "last_14_days": last14, } } // toTehran رشته‌ی زمانیِ UTC را به «زمانِ تهران» با همان قالب تبدیل می‌کند. func toTehran(raw string) string { t, err := time.Parse(tsLayout, raw) if err != nil { return raw } return t.In(iranTZ).Format(tsLayout) } func atoiDefault(s string, def int) int { n, err := strconv.Atoi(strings.TrimSpace(s)) if err != nil { return def } return n }