Files
back-hokm/internal/admin/purchases.go
T

237 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}