feat: add table for purchase
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
# بدونِ رمز (کلیدِ SSH). اجرا: ./deploy.sh
|
# بدونِ رمز (کلیدِ SSH). اجرا: ./deploy.sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HOST="${HAKEM_HOST:-ubuntu@95.38.179.159}"
|
HOST="${HAKEM_HOST:-ubuntu@37.32.20.103}"
|
||||||
KEY="${HAKEM_KEY:-$HOME/.ssh/hakem_deploy}"
|
KEY="${HAKEM_KEY:-$HOME/.ssh/hakem_deploy}"
|
||||||
REMOTE_DIR="/home/ubuntu/hakemsho"
|
REMOTE_DIR="/home/ubuntu/hakemsho"
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -30,6 +30,8 @@ func (h *Handler) APIRoutes() http.Handler {
|
|||||||
r.Post("/users/coins", h.apiUserCoins)
|
r.Post("/users/coins", h.apiUserCoins)
|
||||||
r.Post("/users/grant", h.apiUserGrant)
|
r.Post("/users/grant", h.apiUserGrant)
|
||||||
|
|
||||||
|
r.Get("/purchases", h.apiPurchases)
|
||||||
|
|
||||||
r.Get("/carpets", h.apiCarpets)
|
r.Get("/carpets", h.apiCarpets)
|
||||||
r.Post("/carpets", h.apiCarpetUpsert)
|
r.Post("/carpets", h.apiCarpetUpsert)
|
||||||
r.Delete("/carpets", h.apiCarpetDelete)
|
r.Delete("/carpets", h.apiCarpetDelete)
|
||||||
@@ -73,13 +75,18 @@ func (h *Handler) apiDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = h.db.QueryRowContext(ctx, q).Scan(&n)
|
_ = h.db.QueryRowContext(ctx, q).Scan(&n)
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
stats := h.purchasesStats(ctx)
|
||||||
httpx.JSON(w, http.StatusOK, map[string]any{
|
httpx.JSON(w, http.StatusOK, map[string]any{
|
||||||
"users": count(`SELECT COUNT(*) FROM users`),
|
"users": count(`SELECT COUNT(*) FROM users`),
|
||||||
"purchases": count(`SELECT COUNT(*) FROM purchases WHERE status='verified'`),
|
"purchases": count(`SELECT COUNT(*) FROM purchases WHERE status='verified'`),
|
||||||
"games": count(`SELECT COUNT(*) FROM game_history`),
|
"games": count(`SELECT COUNT(*) FROM game_history`),
|
||||||
"coins": count(`SELECT COALESCE(SUM(coins),0) FROM users`),
|
"coins": count(`SELECT COALESCE(SUM(coins),0) FROM users`),
|
||||||
"tournaments": count(`SELECT COUNT(*) FROM tournaments`),
|
"tournaments": count(`SELECT COUNT(*) FROM tournaments`),
|
||||||
"season": h.eco.Season(ctx),
|
"season": h.eco.Season(ctx),
|
||||||
|
"revenue": stats["total_revenue"],
|
||||||
|
"revenue_today": stats["today"],
|
||||||
|
"revenue_14d": stats["last_14_days"],
|
||||||
|
"recent": h.purchaseRows(ctx, ` WHERE p.status = 'verified'`, nil, 8, 0),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -351,6 +351,7 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
|
|||||||
// را بهدست آوریم؛ سپس با همان SKU خرید را تأیید میکنیم.
|
// را بهدست آوریم؛ سپس با همان SKU خرید را تأیید میکنیم.
|
||||||
var coins, tickets int64
|
var coins, tickets int64
|
||||||
var vipDays int
|
var vipDays int
|
||||||
|
var priceToman int
|
||||||
var booster *Booster
|
var booster *Booster
|
||||||
var sku string
|
var sku string
|
||||||
switch req.Kind {
|
switch req.Kind {
|
||||||
@@ -359,28 +360,28 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
|
|||||||
if p == nil {
|
if p == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
coins, vipDays, sku = p.Coins, p.VIPDays, p.SKU
|
coins, vipDays, priceToman, sku = p.Coins, p.VIPDays, p.PriceToman, p.SKU
|
||||||
if s.isVIP(ctx, userID) {
|
if s.isVIP(ctx, userID) {
|
||||||
coins += coins / 10 // پاداش ۱۰٪ VIP
|
coins += coins / 10 // پاداش ۱۰٪ VIP (قیمت را تغییر نمیدهد)
|
||||||
}
|
}
|
||||||
case "ticket":
|
case "ticket":
|
||||||
p := s.findTicketPackage(req.ProductID)
|
p := s.findTicketPackage(req.ProductID)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
tickets, sku = p.Tickets, p.SKU
|
tickets, priceToman, sku = p.Tickets, p.PriceToman, p.SKU
|
||||||
case "booster":
|
case "booster":
|
||||||
booster = s.findBooster(req.ProductID)
|
booster = s.findBooster(req.ProductID)
|
||||||
if booster == nil {
|
if booster == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
sku = booster.SKU
|
priceToman, sku = booster.PriceToman, booster.SKU
|
||||||
case "vip":
|
case "vip":
|
||||||
p := s.findVIPPackage(req.ProductID)
|
p := s.findVIPPackage(req.ProductID)
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
vipDays, sku = p.Months*30, p.SKU // هر ماه = ۳۰ روز
|
vipDays, priceToman, sku = p.Months*30, p.PriceToman, p.SKU // هر ماه = ۳۰ روز
|
||||||
default:
|
default:
|
||||||
return ErrNotFound
|
return ErrNotFound
|
||||||
}
|
}
|
||||||
@@ -407,9 +408,9 @@ func (s *Service) VerifyPurchase(ctx context.Context, userID int64, req Purchase
|
|||||||
|
|
||||||
// ثبت خرید (یکتا بودن store+token ⇒ ضد دوبارهاعتبارسنجی)
|
// ثبت خرید (یکتا بودن store+token ⇒ ضد دوبارهاعتبارسنجی)
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days)
|
`INSERT INTO purchases (user_id, store, product_id, purchase_token, status, coins, tickets, vip_days, kind, price_toman)
|
||||||
VALUES (?, ?, ?, ?, 'verified', ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, 'verified', ?, ?, ?, ?, ?)`,
|
||||||
userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays); err != nil {
|
userID, req.Store, req.ProductID, req.Token, coins, tickets, vipDays, req.Kind, priceToman); err != nil {
|
||||||
if isUnique(err) {
|
if isUnique(err) {
|
||||||
return ErrAlreadyOwned // قبلاً پردازش شده
|
return ErrAlreadyOwned // قبلاً پردازش شده
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,6 +192,16 @@ func TestPurchaseCreditsAndIdempotent(t *testing.T) {
|
|||||||
if c := coinsOf(t, svc, id); c != pkg.Coins {
|
if c := coinsOf(t, svc, id); c != pkg.Coins {
|
||||||
t.Fatalf("want %d coins, got %d", pkg.Coins, c)
|
t.Fatalf("want %d coins, got %d", pkg.Coins, c)
|
||||||
}
|
}
|
||||||
|
// نوع و قیمتِ خرید باید ثبت شده باشند (پنلِ تراکنشها).
|
||||||
|
var kind string
|
||||||
|
var priceToman int
|
||||||
|
if err := svc.db.QueryRow(`SELECT kind, price_toman FROM purchases WHERE user_id=?`, id).
|
||||||
|
Scan(&kind, &priceToman); err != nil {
|
||||||
|
t.Fatalf("read purchase: %v", err)
|
||||||
|
}
|
||||||
|
if kind != "coin" || priceToman != pkg.PriceToman {
|
||||||
|
t.Fatalf("want kind=coin price=%d, got kind=%q price=%d", pkg.PriceToman, kind, priceToman)
|
||||||
|
}
|
||||||
// همان توکن دوباره ⇒ قبلاً پردازش شده، بدون شارژ مجدد
|
// همان توکن دوباره ⇒ قبلاً پردازش شده، بدون شارژ مجدد
|
||||||
if err := svc.VerifyPurchase(ctx, id, req); err != ErrAlreadyOwned {
|
if err := svc.VerifyPurchase(ctx, id, req); err != ErrAlreadyOwned {
|
||||||
t.Fatalf("want ErrAlreadyOwned, got %v", err)
|
t.Fatalf("want ErrAlreadyOwned, got %v", err)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- تراکنشها: ثبتِ نوع محصول (kind) و قیمتِ پرداختشده (price_toman) در خریدها.
|
||||||
|
-- تا پیش از این، خرید فقط بستهی دریافتی (سکه/بلیط/VIP) را ثبت میکرد؛ نه نوع و نه
|
||||||
|
-- مبلغ را. این دو ستون برای پنلِ «تراکنشها» لازم است و از این پس هنگامِ ثبتِ خرید
|
||||||
|
-- پر میشوند. ردیفهای قدیمی با توجه به کاتالوگِ فعلی backfill میشوند.
|
||||||
|
|
||||||
|
ALTER TABLE purchases ADD COLUMN kind TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE purchases ADD COLUMN price_toman INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
-- Backfill نوع: اولویت سکه → بلیط → VIP → بوستر. ردیفهای غیرِ verified هیچ
|
||||||
|
-- بستهای نگرفتهاند پس نوعشان نامعلوم میماند ('' نه 'booster' تا دوپاره نشود).
|
||||||
|
UPDATE purchases SET kind = CASE
|
||||||
|
WHEN status != 'verified' THEN ''
|
||||||
|
WHEN coins > 0 THEN 'coin'
|
||||||
|
WHEN tickets > 0 THEN 'ticket'
|
||||||
|
WHEN vip_days > 0 THEN 'vip'
|
||||||
|
ELSE 'booster'
|
||||||
|
END
|
||||||
|
WHERE kind = '';
|
||||||
|
|
||||||
|
-- Backfill قیمت؛ با توجه به نوع، چون شناسهی محصول بین جدولهای کاتالوگ مشترک است
|
||||||
|
-- (مثلاً 'small' هم در coin_packages هست و هم در ticket_packages). محصولِ حذفشده → ۰.
|
||||||
|
UPDATE purchases SET price_toman = CASE kind
|
||||||
|
WHEN 'coin' THEN COALESCE((SELECT price_toman FROM coin_packages WHERE id = purchases.product_id), 0)
|
||||||
|
WHEN 'ticket' THEN COALESCE((SELECT price_toman FROM ticket_packages WHERE id = purchases.product_id), 0)
|
||||||
|
WHEN 'booster' THEN COALESCE((SELECT price_toman FROM boosters WHERE id = purchases.product_id), 0)
|
||||||
|
WHEN 'vip' THEN COALESCE((SELECT price_toman FROM vip_packages WHERE id = purchases.product_id), 0)
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
WHERE price_toman = 0;
|
||||||
|
|
||||||
|
-- ایندکس برای فیلترِ وضعیت + آمارِ روزانه/ماهانه در پنلِ تراکنشها.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_purchases_status_created ON purchases(status, created_at);
|
||||||
Reference in New Issue
Block a user